Compare commits

..

3 Commits

117 changed files with 9912 additions and 15797 deletions

View File

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

View File

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

View File

@ -32,6 +32,7 @@
<link rel="stylesheet" href="/css/font.css" /> <link rel="stylesheet" href="/css/font.css" />
<!-- Icons --> <!-- Icons -->
<link rel="stylesheet" href="/vendor/fonts/boxicons.css" /> <link rel="stylesheet" href="/vendor/fonts/boxicons.css" />
<link rel="stylesheet" href="/vendor/fonts/fontawesome.css" /> <link rel="stylesheet" href="/vendor/fonts/fontawesome.css" />

View File

@ -1,41 +0,0 @@
pipeline {
agent any
tools {
nodejs 'nodejs22'
}
stages {
stage('Build'){
steps {
bat 'npm install'
bat 'npm ci'
bat 'npm run build'
}
}
stage('Deploy') {
steps {
// 로컬 Nginx 서버에 빌드 파일 배포
bat '''
:: 루트경로 설정
set NGINX_ROOT=C:\\nginx\\html
:: 기존 빌드 삭제
if exist "%NGINX_ROOT%\\dist" rmdir /s /q "%NGINX_ROOT%\\dist"
:: 빌드 파일 복사
xcopy /s /y dist\\* "%NGINX_ROOT%\\dist\\"
'''
}
}
stage('Restart Server!') {
steps {
// 로컬 Nginx 서버 재실행
bat '''
net stop localnginx
ping -n 5 127.0.0.1 > nul
net start localnginx
ping -n 5 127.0.0.1 > nul
'''
}
}
}
}

8485
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0 --mode dev", "dev": "vite --host 0.0.0.0 --mode dev",
"mine": "vite --host 0.0.0.0 --mode mine", "mine": "vite --host 0.0.0.0 --mode mine",
"build": "vite build --mode dev", "build": "vite build --mode prod",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"format": "prettier --write src/" "format": "prettier --write src/"
@ -19,7 +19,6 @@
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@tinymce/tinymce-vue": "^5.1.1", "@tinymce/tinymce-vue": "^5.1.1",
"@vueup/vue-quill": "^1.2.0", "@vueup/vue-quill": "^1.2.0",
"@vueuse/core": "^13.0.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.11.3",

View File

@ -1,834 +1,215 @@
/* 여기에 light css 작성 */ /* 여기에 light css 작성 */
.opacity-50 {
opacity: 0.5;
}
/* board */
.board-content img {
max-width: 100% !important;
height: auto !important;
display: block;
object-fit: contain;
}
.board-content table {
max-width: 100%;
overflow-x: auto;
display: block;
}
.btn.big {
width: 70px;
height: 70px;
font-size: 18px;
}
@keyframes new {
0% {
background-color: #ffcc00;
}
50% {
background-color: red;
}
100% {
background-color: #ffcc00;
}
}
.box-new {
animation: new 1s infinite; /* 1초마다 반복 */
}
/* board end */
/* Qeditor */
.ql-editor {
min-height: 300px;
font-family: 'Nanum Gothic', sans-serif;
}
/* Qeditor end */
/* 에러페이지 */
/* 전체 화면을 덮는 스타일 */
.error-page {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #fff;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
color: #000;
font-family: 'Poppins', sans-serif;
z-index: 9999 !important;
}
/* 오류 메시지 컨텐츠 */
.error-content {
text-align: center;
animation: fadeIn 0.8s ease-in-out;
}
/* 에러 이미지 */
.error-image {
width: 280px; /* 이미지 크기 */
margin-bottom: 20px;
}
/* 에러 코드 스타일 */
.error-content h1 {
font-size: 6rem;
font-weight: bold;
color: #ff8c00; /* 오렌지 */
text-shadow: 2px 2px 8px rgba(255, 140, 0, 0.3);
margin-bottom: 60px;
}
/* 홈으로 돌아가기 버튼 */
.home-btn {
display: inline-block;
padding: 10px 28px;
font-size: 1rem;
font-weight: bold;
text-decoration: none;
color: #fff;
background: rgba(105, 108, 255, 0.9);
border-radius: 30px;
transition: 0.3s ease-in-out;
box-shadow: 0 4px 15px rgba(105, 108, 255, 0.5);
}
/* 버튼 호버 효과 */
.home-btn:hover {
background: linear-gradient(90deg, orange, #ff8c00);
box-shadow: 0 0 20px rgba(255, 140, 0, 1);
transform: scale(1.05);
color: #fff;
}
/* 페이드 인 애니메이션 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 휴가 */ /* 휴가 */
.fc-daygrid-event {
pointer-events: none !important;
}
/* 이벤트 선 없게 */ /* 이벤트 선 없게 */
.fc-event { .fc-event {
border: none; border: none;
} }
/* 오전 반차 그래프 (왼쪽 절반) */ /* 오전 반차 그래프 (왼쪽 절반) */
.fc-daygrid-event.half-day-am { .fc-daygrid-event.half-day-am {
width: 50% !important; width: 50% !important;
height: 8px !important; height: 8px !important;
border-radius: 2px !important; border-radius: 2px !important;
font-size: 0px !important; font-size: 0px !important;
margin-left: -0.5% !important;
} }
/* 오후 반차 그래프 (오른쪽 절반) */ /* 오후 반차 그래프 (오른쪽 절반) */
.fc-daygrid-event.half-day-pm { .fc-daygrid-event.half-day-pm {
width: 50% !important; width: 50% !important;
height: 8px !important; height: 8px !important;
margin-left: auto !important; margin-left: auto !important;
border-radius: 2px !important; border-radius: 2px !important;
font-size: 0px !important; font-size: 0px !important;
margin-right: -0.5% !important;
} }
/* 연차 그래프 (풀) */ /* 연차 그래프 (풀풀) */
.fc-daygrid-event.full-day { .fc-daygrid-event.full-day {
width: 100% !important; width: 100% !important;
height: 8px !important; height: 8px !important;
margin-left: auto !important; margin-left: auto !important;
border-radius: 2px !important; border-radius: 2px !important;
font-size: 0px !important; font-size: 0px !important;
} }
/* 공휴일,일요일 색상 */ /* 공휴일,일요일 색상 */
.fc-day-sun .fc-daygrid-day-number, .fc-day-sun .fc-daygrid-day-number,
.fc-col-header-cell:first-child .fc-col-header-cell-cushion { .fc-col-header-cell:first-child .fc-col-header-cell-cushion {
color: #ff4500 !important; color: #ff4500 !important;
} }
/* 토요일 색상 */ /* 토요일 색상 */
.fc-day-sat .fc-daygrid-day-number, .fc-day-sat .fc-daygrid-day-number,
.fc-col-header-cell:last-child .fc-col-header-cell-cushion { .fc-col-header-cell:last-child .fc-col-header-cell-cushion {
color: #6076e0 !important; color: #6076e0 !important;
} }
/* 캘린더 날짜 왼쪽 상단 위치하게 */ /* 캘린더 날짜 왼쪽 상단 위치하게 */
.fc-daygrid-day-number { .fc-daygrid-day-number {
margin-right: auto; margin-right: auto;
} }
/* 데이트피커 뾰족없게 */ /* 데이트피커 뾰족없게게 */
.flatpickr-calendar:before, .flatpickr-calendar:before,
.flatpickr-calendar:after { .flatpickr-calendar:after {
display: none !important; display: none !important;
} }
/* 기본 스타일은 그대로 두고, 데이트피커 인풋의 추가 스타일 정의 */ /* 기본 스타일은 그대로 두고, 데이트피커 인풋의 추가 스타일 정의 */
.fc-toolbar-title { .fc-toolbar-title {
cursor: pointer; cursor: pointer;
} }
.fc-today-button { /* 클릭 가능한 날짜 (오늘 + 미래) */
cursor: pointer !important;
}
/* 클릭 가능한 날짜 */
.fc-daygrid-day.clickable { .fc-daygrid-day.clickable {
cursor: pointer; cursor: pointer;
transition: background-color 0.2s ease-in-out; transition: background-color 0.2s ease-in-out;
} }
/* 마우스를 올렸을 때 효과 */ /* 마우스를 올렸을 때 효과 */
.fc-daygrid-day.clickable:hover { .fc-daygrid-day.clickable:hover {
background-color: rgba(0, 0, 0, 0.05); /* 연한 배경 효과 */ background-color: rgba(0, 0, 0, 0.05); /* 연한 배경 효과 */
} }
/* 주말 (토요일, 일요일) 및 공휴일 */ /* 주말 (토요일, 일요일) 및 공휴일 */
.fc-day-sat-sun { .fc-day-sat-sun {
cursor: not-allowed !important; cursor: not-allowed !important;
opacity: 0.6; /* 흐려 보이게 */
} }
/* 과거 날짜 (오늘 -7일일) */ /* 과거 날짜 (오늘 이전) */
.fc-daygrid-day.past { .fc-daygrid-day.past {
cursor: not-allowed !important; cursor: not-allowed !important;
opacity: 0.6; /* 흐려 보이게 */
} }
/* 기본 이벤트 스타일 */ /* 기본 이벤트 스타일 */
.fc-daygrid-event { .fc-daygrid-event {
border: none !important; border: none !important;
border-radius: 4px; border-radius: 4px;
} }
/* 오전 반차 활성화 영역 (왼쪽 절반) */ /* 오전 반차 (왼쪽 절반) */
.selected-event.half-day-am { .selected-event.half-day-am {
width: 50% !important; width: 50% !important;
left: 0 !important; left: 0 !important;
border-top-left-radius: 4px; border-top-left-radius: 4px;
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
} }
/* 오후 반차 활성화 영역 (오른쪽 절반) */ /* 오후 반차 (오른쪽 절반) */
.selected-event.half-day-pm { .selected-event.half-day-pm {
width: 50% !important; width: 50% !important;
margin-left: auto !important; margin-left: auto !important;
border-top-right-radius: 4px; border-top-right-radius: 4px;
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
} }
/* 휴가 모달 */
.vac-modal-dialog { /* 본인 모달 */
background: none !important; /* 닫기 버튼 */
box-shadow: none !important;
display: flex;
align-items: flex-end;
justify-content: center;
width: 100%;
height: 100%;
padding-bottom: 20px;
}
.vac-modal-content {
background: #fff;
padding: 20px;
box-shadow:
0px -4px 5px rgba(0, 0, 0, 0.1),
0px 4px 0px rgba(0, 0, 0, 0);
max-width: 500px;
width: 100%;
position: relative;
}
.vac-modal-body {
max-height: 180px;
overflow-y: auto;
}
.vac-modal-text {
font-size: 14px;
text-align: center;
margin-bottom: 20px;
}
.count-container {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-bottom: 10px;
}
.count-value {
font-size: 23px;
font-weight: bold;
min-width: 50px;
text-align: center;
}
.custom-button {
background: none;
border: none;
width: 55px;
height: 55px;
font-size: 26px;
color: white;
border-radius: 50%;
cursor: pointer;
transition: all 0.2s;
}
.vac-modal-title {
margin-bottom: 10px;
}
.vacation-item {
display: flex;
align-items: center;
font-size: 16px;
font-weight: bold;
margin-bottom: 8px;
padding: 5px 10px;
border-radius: 5px;
background: #f9f9f9;
}
.close-btn { .close-btn {
position: absolute; position: absolute;
top: 10px; top: 10px;
right: 10px; right: 10px;
background: none; background: none;
border: none; border: none;
font-size: 18px; font-size: 18px;
cursor: pointer; cursor: pointer;
} }
.close-btn:hover { /* 리스트 아이템 */
color: #525252; .vacation-item {
display: flex;
align-items: center;
font-size: 16px;
font-weight: bold;
margin-bottom: 8px;
padding: 5px 10px;
border-radius: 5px;
background: #f9f9f9;
} }
/* 선물하기 모달 */
/* 연차 개수 버튼 */
.count-btn { .count-btn {
font-size: 17px; font-size: 18px;
padding: 2px 10px; padding: 2px 10px;
border: none; border: none;
background: #2c3e50; background: #2C3E50;
color: white; color: white;
border-radius: 5px; border-radius: 5px;
cursor: pointer; cursor: pointer;
} }
.count-btn:hover { .count-btn:hover {
background: #1d2c44; background: #1d2c44;
} }
.count-btn:disabled { .count-btn:disabled {
background: #cccccc; background: #cccccc;
cursor: not-allowed; cursor: not-allowed;
} }
/* 버튼 컨테이너 (우측 정렬) */
.custom-button-container { .custom-button-container {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
align-items: center; align-items: center;
} }
.custom-button {
background: none;
border: none;
padding: 10px;
cursor: pointer;
}
.custom-button i {
color: #282538;
font-size: 25px;
}
.custom-button:hover i {
color: #ff0800;
}
.custom-button:disabled {
cursor: not-allowed;
}
/* 휴가 사원프로필 */
.profile-list {
cursor: pointer;
}
/* 오전/오후반차,저장버튼 */
/* 버튼 기본 스타일 */ /* 버튼 기본 스타일 */
.vac-btn { .custom-button {
transition: all 0.2sease-in-out; background: none; /* 배경색 없음 */
border: 2px solid transparent; border: none; /* 테두리 없음 */
padding: 10px; /* 크기 조정 */
cursor: pointer; /* 클릭 가능하도록 변경 */
} }
/* 마우스를 올렸을 때 */ /* 아이콘 색상 변경 (기본) */
.vac-btn:hover { .custom-button i {
filter: brightness(90%); color: #282538; /* 기본 아이콘 색상 */
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.2); font-size: 25px; /* 아이콘 크기 */
transform: scale(1.05);
} }
/* 버튼이 눌렸을 때 */ /* 버튼 호버 효과 */
.vac-btn:active { .custom-button:hover i {
transform: scale(0.9); color: #ff0800; /* 호버 시 아이콘 색상 변경 */
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
}
/* 선택된 (눌린) 버튼 */
.vac-btn.active {
box-shadow: 0px 4px 15px rgba(224, 224, 224, 0.3);
transform: scale(1.1);
}
.vac-btn-warning {
color: #fff;
background-color: #ffc144;
border-color: #ffc144;
box-shadow: 0 0.125rem 0.25rem 0 rgba(255, 171, 0, 0.4);
font-size: 28px;
}
/* AM 버튼 (선택된 상태) */
.vac-btn-warning.active {
background-color: #ff7300 !important;
color: #fff;
}
.vac-btn-info {
color: #fff;
background-color: #03c3ec;
border-color: #03c3ec;
box-shadow: 0 0.125rem 0.25rem 0 rgba(3, 195, 236, 0.4);
font-size: 28px;
}
/* PM 버튼 (선택된 상태) */
.vac-btn-info.active {
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;
background-color: #871919 !important;
color: white;
border: 2px solid transparent;
font-size: 30px;
}
/* 버튼 활성화 */
.vac-btn-success.active {
background-color: #ff0000 !important;
color: white !important;
border: 3px solid #eb9f9f !important;
box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.3);
transform: scale(1.1);
}
/* 버튼 비활성화 */
.vac-btn-success.disabled {
background-color: #bbb8b8 !important;
color: white !important;
cursor: not-allowed !important;
box-shadow: none;
transform: none;
opacity: 0.5;
} }
/* 작은 화면에서 버튼 크기 조정 */
@media (max-width: 1700px) {
.count-btn {
width: 26px;
height: 26px;
font-size: 15px;
}
.count-container {
display: flex;
align-items: center;
justify-content: center;
gap: 0px;
margin-bottom: 8px;
}
.count-value {
font-size: 15px;
}
.custom-button {
width: 45px;
height: 45px;
font-size: 22px;
}
.vac-grant-modal-title {
font-size: 18px;
}
.vac-modal-text {
font-size: 12px;
}
.vac-modal-title {
font-size: 15px;
margin-bottom: 10px;
}
.vacation-item {
font-size: 13px;
text-align: center;
margin-bottom: 5px;
}
.vac-btn {
width: 40px;
height: 40px;
font-size: 18px;
}
.vac-btn-success {
font-size: 20px;
width: 50px;
height: 50px;
}
}
@media (max-width: 1500px) {
.vac-grant-modal-title {
font-size: 14px;
}
.vac-modal-text {
font-size: 11px;
}
.vac-modal-title {
font-size: 13px;
margin-bottom: 10px;
}
.close-btn {
top: 5px;
right: 5px;
font-size: 13px;
}
.vacation-item {
font-size: 11px;
text-align: center;
margin-bottom: 5px;
}
.vac-btn {
width: 10px;
height: 10px;
font-size: 12px;
}
.vac-btn-success {
font-size: 15px;
width: 40px;
height: 40px;
}
}
.grayscaleImg { .grayscaleImg {
filter: grayscale(100%); filter: grayscale(100%);
} }
/* scrollbar 안보이게 */ /* scrollbar 안보이게 */
.scrollbar-none { .scrollbar-none {
scrollbar-width: none; scrollbar-width: none;
} }
/* project list */ /* project list */
.hidden-start-input {
position: absolute;
top: 103%;
left: 20%;
width: 100%;
height: 40px;
opacity: 0;
}
.hidden-end-input {
position: absolute;
top: 113.3%;
left: 20%;
width: 100%;
height: 40px;
opacity: 0;
}
.map { .map {
top: -160px; top: -160px;
left: 90px; left: -5px;
} }
@keyframes sparkle { @keyframes sparkle {
0% { 0% {
color: #ffcc00; color: #ffcc00;
} }
50% { 50% {
color: red; color: red;
} }
100% { 100% {
color: #ffcc00; color: #ffcc00;
} }
} }
.bxs-map { .bxs-map {
animation: sparkle 1s infinite; /* 1초마다 반복 */ animation: sparkle 1s infinite; /* 1초마다 반복 */
} }
.popover-close { .popover-close {
position: absolute; position: absolute;
top: 1rem; top: 1rem;
right: 1rem; right: 1rem;
z-index: 2; z-index: 2;
background-color: #fff !important; background-color: #fff !important;
box-shadow: 0 0.125rem 0.25rem rgba(161, 172, 184, 0.4); border-radius: 0.5rem;
transition: all 0.23s ease 0.1s; opacity: 1;
transform: translate(23px, -25px); padding: 0.635rem;
box-shadow: 0 0.125rem 0.25rem rgba(161, 172, 184, 0.4);
transition: all 0.23s ease 0.1s;
transform: translate(23px, -25px);
} }
.popover-close:hover { .popover-close:hover {
opacity: 1; opacity: 1;
outline: none; outline: none;
transform: translate(20px, -20px); transform: translate(20px, -20px);
} }
.end-project {
background-color: #ddd !important;
}
/* project list end */ /* project list end */
/* commuters */
.commuter-list {
max-height: 450px;
overflow-y: auto;
scrollbar-width: none;
}
.fc-daygrid-day[data-has-commuters='true'] {
cursor: pointer;
}
/* commuters end */
/* Scroll Button */
.scroll-top-btn {
bottom: 40px;
right: 21.7%;
transition:
opacity 0.4s ease,
visibility 0.4s ease,
transform 0.4s ease;
}
.scroll-top-btn.visible {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.scroll-top-btn.hidden {
opacity: 0;
visibility: hidden;
transform: translateY(10px);
}
.back-btn {
bottom: 40px;
right: 21.7%;
transition: transform 0.4s ease;
}
.back-btn.shifted {
transform: translateY(-50px);
}
/* Scroll Button end */
/* 중앙 콘텐츠 자동 조정 */
.layout-page {
flex-grow: 1;
min-width: 0; /* flexbox 내에서 올바른 크기 계산 */
margin-right: 20%; /* 채팅 사이드바의 너비만큼 밀리도록 설정 */
}
/* 중앙 콘텐츠 자동 조정 end */
/* media */
/* 탑바 범위조정 */
.layout-navbar-fixed .layout-navbar.navbar-detached {
width: calc(100% - 1.625rem * 2 - 16.25rem - 20%) !important;
}
@media (min-width: 1200px) {
nav#layout-navbar {
left: calc(16.25rem - 20%) !important;
}
.layout-navbar.navbar-detached.container-xxl {
max-width: calc(1440px - 1.625rem * 2) !important;
}
}
/* 탑바 범위조정(1200px 이하) */
@media (max-width: 1200px) {
nav#layout-navbar {
left: -20% !important;
}
.layout-navbar-fixed .layout-navbar.navbar-detached {
width: calc(100% - 1.625rem * 2 - 20%) !important;
}
}
/* 탑바 범위조정(992px 이하) */
@media (max-width: 992px) {
.layout-navbar-fixed .layout-navbar.navbar-detached {
width: calc(100% - 2rem - 20%) !important;
}
}
/* Mobile */
/* 가로모드 모바일 디바이스 (가로 해상도가 768px 보다 작은 화면에 적용) */
@media (max-width: 767px) {
.chat-sidebar {
display: none !important;
}
nav#layout-navbar {
left: 0 !important;
}
.layout-navbar-fixed .layout-navbar.navbar-detached {
width: calc(100% - 2rem) !important;
}
.layout-page {
margin-right: 0 !important;
}
#app-calendar-sidebar {
width: 100%;
}
}
/* 세로모드 모바일 디바이스 (가로 해상도가 576px 보다 작은 화면에 적용) */
@media (max-width: 575px) {
}
/* Mobile end */
/* media end */
/* BoardComment */
.beforeRotate {
transition: transform 0.3s ease-in-out;
}
.rotate {
transform: rotate(45deg);
transition: transform 0.3s ease-in-out;
}
/* BoardComment end */
/* vote */
.hidden-date-input {
position: absolute;
top: 31.5%;
left: 17%;
width: 100%;
opacity: 0;
pointer-events: none;
}
.hidden-time-input {
position: absolute;
opacity: 0;
pointer-events: none;
}
/* 권한부여 */
.user-card-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
justify-content: center;
margin-top: 20px;
}
.user-card {
width: 200px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 10px;
background-color: #fff;
box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.profile-img {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
margin-bottom: 10px;
}
.switch {
position: relative;
display: inline-block;
width: 50px;
height: 24px;
margin-top: 5px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
transition: 0.4s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: '';
height: 18px;
width: 18px;
left: 4px;
bottom: 3px;
background-color: white;
transition: 0.4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #4caf50;
}
input:checked + .slider:before {
transform: translateX(24px);
}
/* 권한부여 끝 */
/* toast */
.bs-toast {
bottom: 20px;
right: 20px;
}
/* toast end */
.cursor-none {
cursor: none !important;
}
.ml-1 {
margin-left: 0.25rem !important;
}
.mr-1 {
margin-right: 0.25rem !important;
}
.nickname-ellipsis {
white-space: nowrap;
max-width: 100px;
vertical-align: middle;
}
/* font css */
.font-bold {
font-weight: bold;
}
.pointer {
cursor: pointer;
}

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

@ -111,7 +111,5 @@
// If current layout is vertical and current window screen is > small // If current layout is vertical and current window screen is > small
// Auto update menu collapsed/expanded based on the themeConfig // Auto update menu collapsed/expanded based on the themeConfig
// 250304 pc 화면에서 메뉴바 고정을 위해 false 처리 window.Helpers.setCollapsed(true, false);
window.Helpers.setCollapsed(false, false);
//window.Helpers.setCollapsed(true, false);
})(); })();

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,23 +1,21 @@
<template> <template>
<div class="d-flex align-items-center flex-wrap"> <div class="d-flex align-items-center flex-wrap">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="avatar me-2 cursor-none"> <div v-if="!unknown" class="avatar me-2">
<img <img src="/img/avatars/2.png" alt="Avatar" class="rounded-circle" />
:src="getProfileImage(profileImg)"
alt="user"
class="rounded-circle profile-img"
@error="setDefaultImage($event)"
@load="showImage($event)"
/>
</div> </div>
<div class="me-2"> <div class="me-2">
<h6 class="mb-0">{{ profileName ? profileName : nickname }}</h6> <h6 class="mb-0">{{ profileName }}</h6>
<div class="profile-detail"> <div class="profile-detail">
<span>{{ date }}</span> <span>{{ date }}</span>
<template v-if="showDetail"> <template v-if="showDetail">
<span class="ms-2"> <i class="fa-regular fa-eye"></i> {{ views }} </span> <span class="ms-2">
<span v-if="unknown" class="ms-1"> <i class="bx bx-comment"></i> {{ commentNum }} </span> <i class="fa-regular fa-eye"></i> {{ views }}
</span>
<span>
<i class="bx bx-comment"></i> {{ commentNum }}
</span>
</template> </template>
</div> </div>
</div> </div>
@ -25,140 +23,129 @@
<!-- 버튼 영역 --> <!-- 버튼 영역 -->
<div class="ms-auto text-end"> <div class="ms-auto text-end">
<!-- 수정, 삭제 버튼 --> <!-- 수정, 삭제 버튼 -->
<template v-if="!isDeletedComment && (unknown || isCommentAuthor || isAuthor)"> <!-- <template v-if="isAuthor || showDetail"> -->
<div class="float-end ms-1"> <template v-if="unknown || isCommentAuthor || isAuthor">
<slot name="gobackBtn"></slot> <EditButton @click.stop="editClick" />
<EditButton @click.stop="editClick" :is-pushed="isEditPushed" /> <DeleteButton @click.stop="deleteClick" />
<DeleteButton :class="'ms-1'" @click.stop="deleteClick" :is-pushed="isDeletePushed" />
</div>
</template>
<template v-else>
<div class="float-end ms-1">
<slot name="gobackBtn"></slot>
</div>
</template> </template>
<!-- 좋아요, 싫어요 버튼 (댓글에서만 표시) --> <!-- 좋아요, 싫어요 버튼 (댓글에서만 표시) -->
<BoardRecommendBtn <BoardRecommendBtn
v-if="isLike && !isDeletedComment" v-if="isLike"
:boardId="boardId" :boardId="boardId"
:comment="comment" :comment="comment"
:likeClicked="comment.likeClicked"
:dislikeClicked="comment.dislikeClicked"
@updateReaction="handleUpdateReaction" @updateReaction="handleUpdateReaction"
/> >
</BoardRecommendBtn>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, defineProps, defineEmits, inject, onMounted } from 'vue'; import { ref, defineProps, defineEmits } from 'vue';
import DeleteButton from '../button/DeleteBtn.vue'; import DeleteButton from '../button/DeleteBtn.vue';
import EditButton from '../button/EditBtn.vue'; import EditButton from '../button/EditBtn.vue';
import BoardRecommendBtn from '../button/BoardRecommendBtn.vue'; import BoardRecommendBtn from '../button/BoardRecommendBtn.vue';
// Props // Props
const props = defineProps({ const props = defineProps({
comment: { comment: {
type: Object, type: Object,
required: false, required: false,
}, },
boardId: { boardId: {
type: Number, type: Number,
required: false, required: false
}, },
commentId: { commentId: {
type: Number, type: Number,
required: false, required: false,
}, },
profileName: { profileName: {
type: String, type: String,
default: '', default: '',
}, },
nickname: { unknown: {
type: String, type: Boolean,
default: '', default: true,
}, },
unknown: { showDetail: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
showDetail: { // :
type: Boolean, isAuthor: {
default: true, type: Boolean,
}, default: false,
isAuthor: { },
type: Boolean, isCommentAuthor: Boolean, //
default: false, isCommentProfile: Boolean, //
}, date: {
isCommentAuthor: Boolean, type: String,
isCommentProfile: Boolean, required: '',
date: { },
type: String, views: {
required: '', type: Number,
}, default: 0,
views: { },
type: Number, commentNum: {
default: 0, type: Number,
}, default: 0,
commentNum: { },
type: Number, isLike: {
default: 0, type: Boolean,
}, default: false,
isLike: { }
type: Boolean, });
default: false,
}, const emit = defineEmits(['updateReaction', 'editClick', 'deleteClick']);
profileImg: {
type: String, //
default: false, const editClick = () => {
}, emit('editClick', { ...props.comment, unknown: props.unknown });
isEditPushed: { };
type: Boolean,
require: false, //
}, const deleteClick = () => {
isDeletePushed: { emit('deleteClick', { ...props.comment, unknown: props.unknown });
type: Boolean, };
require: false,
}, const handleUpdateReaction = (reactionData) => {
emit("updateReaction", {
boardId: props.boardId,
commentId: props.comment?.commentId,
...reactionData,
}); });
};
const emit = defineEmits(['updateReaction', 'editClick', 'deleteClick']); const getProfileImage = (profilePath) => {
const $common = inject('common'); return profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile;
};
const isDeletedComment = computed(() => {
return props.comment?.content === '삭제된 댓글입니다' && props.comment?.updateAtRaw !== props.comment?.createdAtRaw;
});
//
const editClick = () => {
emit('editClick', { ...props.comment, unknown: props.unknown });
};
//
const deleteClick = () => {
emit('deleteClick', { ...props.comment, unknown: props.unknown });
};
// /
const handleUpdateReaction = reactionData => {
emit('updateReaction', {
boardId: props.boardId,
commentId: props.comment?.commentId,
...reactionData,
});
};
//
const getProfileImage = profileImg => {
return $common.getProfileImage(profileImg, props.unknown);
};
const setDefaultImage = e => {
return $common.setDefaultImage(e);
};
const showImage = e => {
return $common.showImage(e);
};
</script> </script>
<style scoped>
.profile-detail span ~ span {
margin-left: 5px;
}
.ms-auto button + button {
margin-left: 5px;
}
.btn.author {
height: 30px;
}
@media screen and (max-width: 450px) {
.btn-area {
margin-top: 10px;
width: 100%;
}
.btn.author {
height: 30px;
}
}
</style>

View File

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

View File

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

View File

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

View File

@ -1,72 +1,135 @@
<template> <template>
<div class="row gx-2 mb-10 mt-1"> <div class="menu gap-4 justify-content-center mt-5">
<div class="col-3"> <!-- 오전 반차 버튼 -->
<div class="ratio ratio-1x1"> <button
<!-- 오전 반차 버튼 --> class="btn btn-warning"
<button class="vac-btn vac-btn-warning rounded-circle d-flex align-items-center justify-content-center" :class="{ active: halfDayType === 'AM' }"
:class="{ active: halfDayType === 'AM' }" @click="toggleHalfDay('AM')"
@click="toggleHalfDay('AM')"> >
<i class="bi bi-sun d-flex"></i> <i class="bi bi-sun"></i>
</button> </button>
</div>
</div> <!-- 오후 반차 버튼 -->
<div class="col-3"> <button
<div class="ratio ratio-1x1"> class="btn btn-info"
<!-- 오후 반차 버튼 --> :class="{ active: halfDayType === 'PM' }"
<button class="vac-btn vac-btn-info rounded-circle d-flex align-items-center justify-content-center" @click="toggleHalfDay('PM')"
:class="{ active: halfDayType === 'PM' }" >
@click="toggleHalfDay('PM')"> <i class="bi bi-moon"></i>
<i class="bi bi-moon d-flex"></i> </button>
</button>
</div> <!-- 저장 버튼 -->
</div> <div class="save-button-container">
<div class="col-3"> <button class="btn btn-success" @click="addVacationRequests" :disabled="isDisabled">
<div class="ratio ratio-1x1">
<!-- 연차 버튼 --> </button>
<button class="vac-btn vac-btn-primary rounded-circle d-flex align-items-center justify-content-center" </div>
: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>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { defineEmits, ref, defineProps } from "vue"; import { defineEmits, ref, defineProps, watch } from "vue";
const props = defineProps({ const props = defineProps({
isDisabled: Boolean isDisabled: Boolean,
selectedDate: String // props
}); });
const emit = defineEmits(["toggleHalfDay", "addVacationRequests", "resetHalfDay"]); const emit = defineEmits(["toggleHalfDay", "addVacationRequests", "resetHalfDay"]);
const halfDayType = ref(null); const halfDayType = ref(null);
const toggleHalfDay = (type) => { const toggleHalfDay = (type) => {
halfDayType.value = halfDayType.value === type ? null : type; halfDayType.value = type;
emit("toggleHalfDay", halfDayType.value); emit("toggleHalfDay", halfDayType.value);
}; };
// // `selectedDate`
watch(() => props.selectedDate, (newDate) => {
if (newDate) {
resetHalfDay();
}
});
//
const resetHalfDay = () => { const resetHalfDay = () => {
halfDayType.value = null; halfDayType.value = null;
emit("resetHalfDay"); emit("resetHalfDay");
}; };
const addVacationRequests = () => { const addVacationRequests = () => {
emit("addVacationRequests"); emit("addVacationRequests");
}; };
defineExpose({ resetHalfDay }); defineExpose({ resetHalfDay });
</script> </script>
<style scoped>
/* 버튼 기본 스타일 */
.btn {
width: 50px;
height: 50px;
border-radius: 50%;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease-in-out;
}
/* 마우스를 올렸을 때 */
.btn:hover {
filter: brightness(90%);
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.2);
transform: scale(1.05);
}
/* 버튼이 눌렸을 때 */
.btn:active {
transform: scale(0.9);
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
}
/* 선택된 (눌린) 버튼 */
.btn.active {
border: 3px solid #fff;
box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.3);
transform: scale(1.1);
}
/* AM 버튼 (선택된 상태) */
.btn-warning.active {
background-color: #ffca2c !important;
color: black;
}
/* PM 버튼 (선택된 상태) */
.btn-info.active {
background-color: #0b5ed7 !important;
color: white;
}
/* ✔ 버튼 */
.btn-success {
font-size: 24px;
width: 50px;
height: 50px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease-in-out;
}
/* ✔ 버튼 마우스 오버 */
.btn-success:hover {
background-color: #198754;
box-shadow: 0px 4px 10px rgba(25, 135, 84, 0.4);
transform: scale(1.1);
}
/* ✔ 버튼 클릭 */
.btn-success:active {
transform: scale(0.95);
box-shadow: 0px 2px 5px rgba(25, 135, 84, 0.2);
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,223 +0,0 @@
<template>
<div class="row g-0">
<div class="col-6 pe-1">
<button
class="btn border-3 w-100 py-0 h-px-50"
:class="workTime ? 'p-0 btn-primary pe-none' : 'btn-outline-primary'"
@click="setWorkTime"
>
<i v-if="!workTime" class="bx bx-run fs-2"></i>
<span v-if="workTime" class="ql-size-12px">{{ workTime }}</span>
</button>
</div>
<div class="col-6 ps-1">
<button
class="btn border-3 w-100 py-0 h-px-50"
:class="!workTime ? 'btn-outline-secondary pe-none disabled' : 'btn-outline-secondary'"
@click="setLeaveTime"
:disabled="!workTime"
>
<i v-if="!leaveTime" class='bx bxs-door-open fs-2'></i>
<span v-if="leaveTime" class="ql-size-12px">{{ leaveTime }}</span>
</button>
</div>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, onMounted, watch } from 'vue';
import $api from '@api';
import { useGeolocation } from '@vueuse/core';
const props = defineProps({
userId: {
type: Number,
required: false
},
checkedInProject: {
type: Object,
required: false
},
pendingProjectChange: {
type: Object,
default: null
}
});
const emit = defineEmits(['workTimeUpdated', 'leaveTimeUpdated', 'projectChangeComplete', 'update:pendingProjectChange']);
const workTime = ref(null);
const leaveTime = ref(null)
const userLocation = ref(null);
//
const { coords, isSupported, error } = useGeolocation({
enableHighAccuracy: true,
});
//
const getAddress = async (lat, lng) => {
return new Promise((resolve, reject) => {
if (typeof kakao === 'undefined' || !kakao.maps) {
reject('Kakao Maps API가 로드되지 않았습니다.');
return;
}
const geocoder = new kakao.maps.services.Geocoder();
geocoder.coord2Address(lng, lat, (result, status) => {
if (status === kakao.maps.services.Status.OK) {
if (result && result.length > 0 && result[0].address) {
const address = result[0].address.address_name;
resolve(address);
} else {
//
reject('주소 정보가 없습니다.');
}
} else if (status === kakao.maps.services.Status.ZERO_RESULT) {
// ZERO_RESULT
//
geocoder.coord2RegionCode(lng, lat, (regionResult, regionStatus) => {
if (regionStatus === kakao.maps.services.Status.OK && regionResult.length > 0) {
//
const region = regionResult[0].address_name;
resolve(`[대략적 위치] ${region}`);
} else {
//
resolve(`위도: ${lat}, 경도: ${lng} (주소 정보 없음)`);
}
});
} else {
reject(`주소를 가져올 수 없습니다. 상태: ${status}`);
}
});
});
};
//
const getLocation = async () => {
if (!isSupported.value) {
alert('브라우저가 위치 정보를 지원하지 않습니다.');
return null;
}
if (error.value) {
alert(`위치 정보를 가져오는데 실패했습니다: ${error.value.message}`);
return null;
}
if (!coords.value) {
return null;
}
userLocation.value = {
lat: coords.value.latitude,
lng: coords.value.longitude,
};
try {
const address = await getAddress(coords.value.latitude, coords.value.longitude);
return address;
} catch (error) {
alert(error);
return null;
}
};
//
const todayCommuterInfo = async () => {
if (!props.userId) return;
const res = await $api.get(`commuters/today/${props.userId}`);
if (res.status === 200) {
const commuterInfo = res.data.data[0];
if (commuterInfo) {
workTime.value = commuterInfo.COMMUTCMT;
leaveTime.value = commuterInfo.COMMUTLVE;
//
emit('workTimeUpdated', workTime.value);
emit('leaveTimeUpdated', leaveTime.value);
}
}
};
//
const setWorkTime = async () => {
//
if (workTime.value) return;
//
const address = await getLocation();
if (!address) {
//
if (!confirm('위치 정보를 가져오지 못했습니다. 위치 없이 출근 처리하시겠습니까?')) {
return;
}
}
$api.post('commuters/insert', {
memberSeq: props.userId,
projctSeq: props.checkedInProject.PROJCTSEQ,
commutLve: null,
commutArr: address,
commutOut: null,
}).then(res => {
if (res.status === 200) {
todayCommuterInfo();
emit('workTimeUpdated', true);
}
});
};
//
const setLeaveTime = async () => {
//
const address = await getLocation();
if (!address && !leaveTime.value) {
//
if (!confirm('위치 정보를 가져오지 못했습니다. 위치 없이 퇴근 처리하시겠습니까?')) {
return;
}
}
$api.patch('commuters/updateLve', {
memberSeq: props.userId,
commutLve: leaveTime.value || null,
projctLve: props.pendingProjectChange ? props.pendingProjectChange.projctSeq : props.checkedInProject.PROJCTSEQ,
commutOut: address,
}).then(res => {
if (res.status === 200) {
todayCommuterInfo();
emit('leaveTimeUpdated');
emit('update:pendingProjectChange', null);
}
});
};
// props
watch(() => props.userId, async () => {
if (props.userId) {
await todayCommuterInfo();
}
});
onMounted(async () => {
await todayCommuterInfo();
});
//
defineExpose({
todayCommuterInfo,
workTime,
leaveTime
});
</script>

View File

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

View File

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

View File

@ -41,276 +41,158 @@
<button class="ql-link">Link</button> <button class="ql-link">Link</button>
<button class="ql-image">Image</button> <button class="ql-image">Image</button>
<button class="ql-video">Video</button>
<button class="ql-blockquote">Blockquote</button> <button class="ql-blockquote">Blockquote</button>
<button class="ql-code-block">Code Block</button> <button class="ql-code-block">Code Block</button>
</div> </div>
<!-- 에디터가 표시될 div --> <!-- 에디터가 표시될 div -->
<div id="qEditor" ref="editor"></div> <div ref="editor"></div>
<!-- Alert 메시지 표시 --> <!-- Alert 메시지 표시 -->
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">내용을 확인해주세요.</div> <div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">내용을 확인해주세요.</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import Quill from 'quill'; import Quill from 'quill';
import 'quill/dist/quill.snow.css'; import 'quill/dist/quill.snow.css';
import $api from '@api'; import { onMounted, ref, watch, defineEmits, defineProps } from 'vue';
import { onMounted, ref, watch, defineEmits, defineProps } from 'vue'; import $api from '@api';
import { useToastStore } from '@s/toastStore';
const toastStore = useToastStore();
const props = defineProps({ const props = defineProps({
isAlert: { isAlert: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
initialData: { initialData: {
type: [String, Object], type: String,
default: () => null, default: () => null,
},
});
const editor = ref(null); // DOM
const font = ref('nanum-gothic'); //
const fontSize = ref('16px'); //
const emit = defineEmits(['update:data']);
onMounted(() => {
//
const Font = Quill.import('formats/font');
Font.whitelist = ['nanum-gothic', 'd2coding', 'consolas', 'serif', 'monospace'];
Quill.register(Font, true);
//
const Size = Quill.import('attributors/style/size');
Size.whitelist = ['12px', '14px', '16px', '18px', '24px', '32px', '48px'];
Quill.register(Size, true);
// Quill
const quillInstance = new Quill(editor.value, {
theme: 'snow',
placeholder: '내용을 입력해주세요...',
modules: {
toolbar: {
container: '#toolbar',
},
syntax: true,
}, },
}); });
const editor = ref(null); // DOM //
const font = ref('nanum-gothic'); // quillInstance.format('font', font.value);
const fontSize = ref('16px'); // quillInstance.format('size', fontSize.value);
const emit = defineEmits(['update:data', 'update:uploadedImgList', 'update:deleteImgIndexList']);
const uploadedImgList = ref([]); //
const initImageIndex = ref([]); //
const deleteImgIndexList = ref([]); //
onMounted(() => { //
// quillInstance.on('text-change', () => {
const Font = Quill.import('formats/font'); const delta = quillInstance.getContents(); // Delta
Font.whitelist = ['nanum-gothic', 'd2coding', 'consolas', 'serif', 'monospace']; emit('update:data', delta);
Quill.register(Font, true); });
// //
const Size = Quill.import('attributors/style/size'); watch([font, fontSize], () => {
Size.whitelist = ['12px', '14px', '16px', '18px', '24px', '32px', '48px'];
Quill.register(Size, true);
// Quill
const quillInstance = new Quill(editor.value, {
theme: 'snow',
placeholder: '내용을 입력해주세요...',
modules: {
toolbar: {
container: '#toolbar',
},
syntax: true,
},
});
//
quillInstance.format('font', font.value); quillInstance.format('font', font.value);
quillInstance.format('size', fontSize.value); quillInstance.format('size', fontSize.value);
//
quillInstance.on('text-change', () => {
const delta = quillInstance.getContents(); // Delta
emit('update:data', delta);
});
//
watch([font, fontSize], () => {
quillInstance.format('font', font.value);
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', () => {
selectLocalImage(); //
});
//
quillInstance.on('text-change', (delta, oldDelta, source) => {
delta.ops.forEach(op => {
if (op.insert && typeof op.insert === 'object' && op.insert.image) {
const imageUrl = op.insert.image; // URL
imageUrls.add(imageUrl); // URL
} else if (op.delete) {
checkForDeletedImages(); //
}
});
checkDeletedImages();
emit('update:data', quillInstance.getContents());
});
//
async function selectLocalImage() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click(); //
input.onchange = () => {
const file = input.files[0];
if (file) {
const formData = new FormData();
formData.append('file', file);
// 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];
}
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
//const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}`;
const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}?imgIndex=${uploadImgIdx}`; // index
const range = quillInstance.getSelection();
quillInstance.insertEmbed(range.index, 'image', fullImageUrl); //
imageUrls.add(fullImageUrl); // URL
})
.catch(e => {
toastStore.onToast('잠시후 다시 시도해주세요.', 'e');
});
}
};
}
//
async function uploadImageToServer(formData) {
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;
}
}
//
function checkForDeletedImages() {
const editorImages = document.querySelectorAll('#qEditor img');
const currentImages = new Set(Array.from(editorImages).map(img => img.src)); //
imageUrls.forEach(url => {
if (!currentImages.has(url)) {
imageUrls.delete(url);
}
});
}
//
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;
}
}); });
// , HTML
if (props.initialData) {
quillInstance.setContents(JSON.parse(props.initialData));
}
//
let imageUrls = new Set(); // URL
quillInstance.getModule('toolbar').addHandler('image', () => {
selectLocalImage(); //
});
//
quillInstance.on('text-change', (delta, oldDelta, source) => {
emit('update:data', quillInstance.getContents());
delta.ops.forEach(op => {
if (op.insert && typeof op.insert === 'object' && op.insert.image) {
const imageUrl = op.insert.image; // URL
imageUrls.add(imageUrl); // URL
} else if (op.delete) {
checkForDeletedImages(); //
}
});
});
//
async function selectLocalImage() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click(); //
input.onchange = () => {
const file = input.files[0];
if (file) {
const formData = new FormData();
formData.append('file', file);
// URL
uploadImageToServer(formData).then(serverImageUrl => {
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}`;
const range = quillInstance.getSelection();
quillInstance.insertEmbed(range.index, 'image', fullImageUrl); //
imageUrls.add(fullImageUrl); // URL
}).catch(e => {
toastStore.onToast('잠시후 다시 시도해주세요.', 'e');
});
}
};
}
//
async function uploadImageToServer(formData) {
try {
const response = await $api.post('quilleditor/upload', formData, { isFormData: true });
const imageUrl = response.data.data;
return imageUrl; // URL
} catch (error) {
toastStore.onToast('잠시후 다시 시도해주세요.', 'e');
throw error;
}
}
//
function checkForDeletedImages() {
const editorImages = document.querySelectorAll('#editor img');
const currentImages = new Set(Array.from(editorImages).map(img => img.src)); //
imageUrls.forEach(url => {
if (!currentImages.has(url)) {
imageUrls.delete(url);
}
});
}
});
</script> </script>
<style>
@import 'quill/dist/quill.snow.css';
.ql-editor {
min-height: 300px;
font-family: 'Nanum Gothic', sans-serif;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,125 +1,145 @@
<template> <template>
<div v-if="isOpen" class="vac-modal-dialog" @click.self="closeModal"> <div v-if="isOpen" class="modal-dialog" @click.self="closeModal">
<div class="vac-modal-content"> <div class="modal-content p-5">
<div class="vac-modal-header"> <h5 class="modal-title">To. {{ targetUser.MEMBERNAM }} 🎁</h5>
<h5 class="vac-grant-modal-title">To. {{ targetUser.MEMBERNAM }} 🎁</h5> <button class="close-btn" @click="closeModal"></button>
<button class="close-btn" @click="closeModal"></button>
</div>
<div class="vac-modal-body">
<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">
<i class="bx bx-gift"></i>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup> <div class="modal-body">
import { ref, defineProps, defineEmits, watch, onMounted, computed } from "vue"; <p>선물할 연차 개수를 선택하세요.</p>
import axios from "@api";
import { useToastStore } from '@s/toastStore'; <div class="justify-content-center d-sm-flex gap-sm-3 align-items-md-center mt-8">
import { useUserInfoStore } from "@s/useUserInfoStore"; <button @click="decreaseCount" :disabled="grantCount < 2" class="count-btn">-</button>
const userStore = useUserInfoStore(); <span class="text-dark fw-bold fs-4">{{ grantCount }}</span>
const toastStore = useToastStore(); <button @click="increaseCount" :disabled="grantCount >= availableQuota" class="count-btn">+</button>
const props = defineProps({ </div>
<div class="custom-button-container">
<button class="custom-button" @click="saveVacationGrant" :disabled="grantCount === 0">
<i class="bx bx-gift"></i>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, watch, onMounted } from "vue";
import axios from "@api";
import { useToastStore } from '@s/toastStore';
const toastStore = useToastStore();
const props = defineProps({
isOpen: Boolean, isOpen: Boolean,
targetUser: Object, targetUser: Object,
remainingVacationData: Object, });
});
const emit = defineEmits(["close", "updateVacation"]); const emit = defineEmits(["close", "updateVacation"]);
const grantCount = ref(0); const grantCount = ref(0);
const maxQuota = 2; const maxQuota = 2;
const sentCount = ref(0); const sentCount = ref(0);
const availableQuota = ref(2); const availableQuota = ref(2);
const myUserId = computed(() => userStore.user.id); const fetchSentVacationCount = async () => {
const myRemainingQuota = computed(() => {
return props.remainingVacationData?.[myUserId.value] ?? 0;
});
const isGiftButtonDisabled = computed(() => {
return myRemainingQuota.value < 0;
});
//
const fetchSentVacationCount = async () => {
try { try {
const payload = { receiverId: props.targetUser.MEMBERSEQ }; const payload = { receiverId: props.targetUser.MEMBERSEQ };
const response = await axios.get("vacation/sent", { params: payload }); const response = await axios.get("vacation/sent", { params: payload });
sentCount.value = response.data.data[0]?.count || 0; sentCount.value = response.data.data[0].count || 0;
availableQuota.value = Math.max(maxQuota - sentCount.value, 0); availableQuota.value = Math.max(maxQuota - sentCount.value, 0);
grantCount.value = availableQuota.value; grantCount.value = availableQuota.value; //
} catch (error) { } catch (error) {
availableQuota.value = maxQuota; console.error("🚨 연차 전송 기록 조회 실패:", error);
grantCount.value = maxQuota; availableQuota.value = maxQuota;
grantCount.value = maxQuota; //
} }
}; };
// const increaseCount = () => {
const increaseCount = () => {
if (grantCount.value < availableQuota.value) { if (grantCount.value < availableQuota.value) {
grantCount.value++; grantCount.value++;
} }
}; };
//
const decreaseCount = () => { const decreaseCount = () => {
if (grantCount.value > 0) { if (grantCount.value > 0) {
grantCount.value--; grantCount.value--;
} }
}; };
// const saveVacationGrant = async () => {
const saveVacationGrant = async () => {
try { try {
const payload = [{ const payload = [
date: new Date().toISOString().split("T")[0], {
type: "700103", date: new Date().toISOString().split("T")[0],
receiverId: props.targetUser.MEMBERSEQ, type: "700103",
count: grantCount.value, receiverId: props.targetUser.MEMBERSEQ,
}]; count: grantCount.value,
const response = await axios.post("vacation", payload); },
if (response.data?.status === "OK") { ];
toastStore.onToast('연차가 선물되었습니다.', 's'); const response = await axios.post("vacation", payload);
await fetchSentVacationCount(); if (response.data && response.data.status === "OK") {
emit("updateVacation"); toastStore.onToast('연차가 선물되었습니다.', 's');
closeModal(); await fetchSentVacationCount();
} else { emit("updateVacation");
toastStore.onToast(' 연차 선물 중 오류가 발생했습니다.', 'e'); closeModal();
} } else {
toastStore.onToast(' 연차 선물 중 오류가 발생했습니다.', 'e');
}
} catch (error) { } catch (error) {
toastStore.onToast(' 연차 선물 실패!!.', 'e'); console.error("🚨 연차 추가 실패:", error);
toastStore.onToast(' 연차 선물 실패!!.', 'e');
} }
}; };
// const closeModal = () => {
const closeModal = () => {
emit("close"); emit("close");
}; };
watch(() => props.isOpen, async (newVal) => { watch(
if (newVal && props.targetUser?.MEMBERSEQ) { () => props.isOpen,
async (newVal) => {
if (newVal && props.targetUser && props.targetUser.MEMBERSEQ) {
await fetchSentVacationCount(); await fetchSentVacationCount();
}
} }
}); );
watch(() => props.targetUser, async (newUser) => {
if (newUser?.MEMBERSEQ) {
await fetchSentVacationCount();
}
}, { deep: true });
onMounted(async () => { watch(
if (props.isOpen && props.targetUser?.MEMBERSEQ) { () => props.targetUser,
async (newUser) => {
if (newUser && newUser.MEMBERSEQ) {
await fetchSentVacationCount(); await fetchSentVacationCount();
} }
}); },
</script> { deep: true }
);
<style scoped> onMounted(async () => {
</style> if (props.isOpen && props.targetUser && props.targetUser.MEMBERSEQ) {
await fetchSentVacationCount();
}
});
</script>
<style scoped>
/* 모달 배경 투명하게 */
.modal-dialog {
background: none !important; /* 배경 제거 */
box-shadow: none !important; /* 음영 제거 */
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* 모달 내용 스타일 */
.modal-content {
background: #fff; /* 기존 흰색 배경 유지 */
border-radius: 8px;
box-shadow: none !important; /* 내부 음영 제거 */
padding: 20px;
max-width: 500px;
width: 100%;
}
</style>

View File

@ -1,126 +1,162 @@
<template> <template>
<div v-if="isOpen" class="vac-modal-dialog" @click.self="closeModal"> <div v-if="isOpen" class="modal-dialog" @click.self="closeModal">
<div class="vac-modal-content p-5 modal-scroll"> <div class="modal-content p-5 modal-scroll">
<h5 class="vac-modal-title">📅 연차 (누적 개수)</h5> <h5 class="modal-title">📅 연차 사용 내역</h5>
<button class="close-btn" @click="closeModal"></button> <button class="close-btn" @click="closeModal"></button>
<!-- 연차 목록 -->
<div class="vac-modal-body" v-if="mergedVacations.length > 0"> <!-- 연차 목록 -->
<ol class="list-group-numbered px-0 mt-4"> <div class="modal-body" v-if="mergedVacations.length > 0">
<li <ol class="list-group-numbered px-0 mt-4">
v-for="(vac, index) in mergedVacations" <li
:key="vac._expandIndex" v-for="(vac, index) in mergedVacations"
class="vacation-item" :key="vac._expandIndex"
> class="vacation-item"
<span :class="vac.category === 'used' ? 'fw-bold text-danger me-2' : 'fw-bold text-primary me-2'"> >
{{ vac.category === 'used' ? '-' : '+' }} <!-- Used 항목만 인덱스 표시 -->
</span> <span v-if="vac.category === 'used'" class="fw-bold text-dark me-2">
<span {{ usedVacationIndexMap[vac._expandIndex] }})
:style="{ color: userColors[vac.senderId || vac.receiverId] || '#000' }" </span>
>
{{ formatDate(vac.date) }} <span :class="vac.category === 'used' ? 'fw-bold text-danger me-2' : 'fw-bold text-primary me-2'">
</span> {{ vac.category === 'used' ? '-' : '+' }}
<span v-if="vac.category === 'used'" class="fw-bold text-dark ms-1"> </span>
( {{ usedVacationIndexMap[vac._expandIndex] }} )
</span> <span
</li> :style="{ color: userColors[vac.senderId || vac.receiverId] || '#000' }"
</ol> >
</div> {{ formatDate(vac.date) }}
<!-- 연차 데이터 없음 --> </span>
<p v-else class="text-sm-center mt-10 text-gray vac-modal-title"> </li>
🚫 연차 내역이 없습니다. </ol>
</p>
</div> </div>
<!-- 연차 데이터 없음 -->
<p v-else class="text-sm-center mt-10 text-gray">
🚫 사용한 연차가 없습니다.
</p>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { defineProps, defineEmits, computed } from "vue"; import { defineProps, defineEmits, computed } from "vue";
import { formatDate } from '@/common/formattedDate.js'; import { formatDate } from '@/common/formattedDate.js';
const props = defineProps({ const props = defineProps({
isOpen: Boolean, isOpen: Boolean,
myVacations: { myVacations: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
receivedVacations: { receivedVacations: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
userColors: { userColors: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
}); });
const emit = defineEmits(["close"]); const emit = defineEmits(["close"]);
// (,) /**
let globalCounter = 0; * 1) Used 휴가를 used_quota만큼 펼치기
* - category: "used"
* - code: 휴가 코드(: LOCVACTYP)
* - _expandIndex: 항목마다 고유한 확장 인덱스 (누적 인덱스 매핑에 사용)
*/
let globalCounter = 0; //
const usedVacations = computed(() => { const usedVacations = computed(() => {
const data = props.myVacations.flatMap((v) => { const result = [];
props.myVacations.forEach((v) => {
const count = v.used_quota const count = v.used_quota || 1;
return Array.from({ length: Math.ceil(count) }, (_, i) => ({ for (let i = 0; i < count; i++) {
...v, result.push({
category: "used", ...v,
code: v.LOCVACTYP, category: "used",
used_quota: count, // code: v.LOCVACTYP, // (700103 )
_expandIndex: globalCounter++, _expandIndex: globalCounter++,
})); });
}); }
return data; });
return result;
}); });
// /**
const receivedVacations = computed(() => { * 2) Received 휴가: category: "received"
const data = props.receivedVacations.flatMap((v) => { */
const count = v.received_quota ?? 1; const receivedVacations = computed(() =>
return Array.from({ length: Math.ceil(count) }, (_, i) => ({ props.receivedVacations.map((v) => ({
...v, ...v,
category: "received", category: "received",
_expandIndex: globalCounter++, }))
})); );
});
return data; /**
}); * 3) Used 휴가만 날짜 오름차순 정렬 누적 인덱스 계산
// * - type === "700103"이면 +1
* - 외이면 +0.5
*/
const sortedUsedVacationsAsc = computed(() => { const sortedUsedVacationsAsc = computed(() => {
return [...usedVacations.value].sort((a, b) => { return [...usedVacations.value].sort((a, b) => {
return new Date(a.date) - new Date(b.date) || (a._expandIndex - b._expandIndex); return new Date(a.date) - new Date(b.date) || (a._expandIndex - b._expandIndex);
}); });
}); });
// type === "700103" +1 +0.5
const usedVacationIndexMap = computed(() => { const usedVacationIndexMap = computed(() => {
let cumulative = 0; let cumulative = 0;
const map = {}; const map = {};
sortedUsedVacationsAsc.value.forEach((item) => { sortedUsedVacationsAsc.value.forEach((item) => {
const increment = item.type === "700103" ? 1 : 0.5; const increment = item.type === "700103" ? 1 : 0.5;
cumulative += increment; cumulative += increment;
map[item._expandIndex] = cumulative; map[item._expandIndex] = cumulative;
}); });
return map; return map;
}); });
// merged (Used + Received) ( ) /**
* 4) 최종 표시할 merged 리스트 (Used + Received)
* - 날짜 내림차순 정렬 (최신 과거)
*/
const mergedVacations = computed(() => { const mergedVacations = computed(() => {
const all = [...usedVacations.value, ...receivedVacations.value]; const all = [...usedVacations.value, ...receivedVacations.value];
// + // +
all.sort((a, b) => { all.sort((a, b) => {
const dateDiff = new Date(b.date) - new Date(a.date); const dateDiff = new Date(b.date) - new Date(a.date);
if (dateDiff !== 0) return dateDiff; if (dateDiff !== 0) return dateDiff;
return b._expandIndex - a._expandIndex; return b._expandIndex - a._expandIndex;
}); });
return all; return all;
}); });
//
/** 모달 닫기 */
const closeModal = () => { const closeModal = () => {
emit("close"); emit("close");
}; };
</script> </script>
<style scoped>
</style>
<style scoped>
/* 모달 배경 투명하게 */
.modal-dialog {
background: none !important; /* 배경 제거 */
box-shadow: none !important; /* 음영 제거 */
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* 모달 내용 스타일 */
.modal-content {
background: #fff; /* 기존 흰색 배경 유지 */
border-radius: 8px;
box-shadow: none !important; /* 내부 음영 제거 */
padding: 20px;
max-width: 500px;
width: 100%;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,108 +1,136 @@
<template> <template>
<ul class="row gx-2 mb-0 list-inline "> <div class="card-body d-flex justify-content-center m-n5">
<ul class="list-unstyled d-flex flex-wrap align-items-center gap-2 mb-0 mt-2">
<li <li
v-for="(user, index) in sortedUserList" v-for="(user, index) in sortedUserList"
:key="index" :key="index"
class="col-4 mb-3" :class="{ disabled: user.disabled }"
:class="{ newRow: (index + 1) % 4 === 0 }" @click="$emit('profileClick', user)"
@click="$emit('profileClick', user)" data-bs-placement="top"
data-bs-placement="top" :aria-label="user.MEMBERSEQ"
:aria-label="user.MEMBERSEQ"
> >
<div class="ratio ratio-1x1 mb-0 profile-list position-relative"> <img
<img class="rounded-circle"
v-if="user.MEMBERSEQ === employeeId" :src="getUserProfileImage(user.MEMBERPRF)"
src="/img/icons/Crown.png" alt="user"
alt="Crown" :style="getDynamicStyle(user)"
class="start-50 translate-middle crown-icon" @error="setDefaultImage"
/> @load="showImage"
<img />
class="rounded-circle object-fit-cover" <span class="remaining-vacation">
:src="getUserProfileImage(user.MEMBERPRF)" {{ remainingVacationData[user.MEMBERSEQ] || 0 }}
alt="user" </span>
: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> </li>
</ul> </ul>
</template> </div>
</template>
<script setup> <script setup>
import { onMounted, ref, computed, nextTick } from "vue"; import { onMounted, ref, computed, nextTick } from "vue";
import { useUserInfoStore } from "@s/useUserInfoStore"; import { useUserInfoStore } from "@s/useUserInfoStore";
import { useUserStore } from "@s/userList"; import { useUserStore } from "@s/userList";
import $api from "@api"; import $api from "@api";
defineEmits(["profileClick"]); defineEmits(["profileClick"]);
defineProps({ remainingVacationData: Object });
const userStore = useUserInfoStore(); defineProps({
const userListStore = useUserStore(); remainingVacationData: Object,
});
const userList = ref([]); const userStore = useUserInfoStore();
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, ""); const userListStore = useUserStore();
const defaultProfile = "/img/icons/icon.png";
const employeeId = ref(null);
const userColors = ref({});
const windowWidth = ref(window.innerWidth);
const updateWindowWidth = () => { const userList = ref([]);
windowWidth.value = window.innerWidth; const baseUrl = $api.defaults.baseURL.replace(/api\/$/, "");
}; const defaultProfile = "/img/icons/icon.png";
const employeeId = ref(null);
const userColors = ref({});
onMounted(async () => { onMounted(async () => {
window.addEventListener("resize", updateWindowWidth); await userStore.userInfo();
if (userStore.user) {
employeeId.value = userStore.user.id;
} else {
console.error("❌ 로그인한 사용자 정보를 불러오지 못했습니다.");
}
await userStore.userInfo(); await userListStore.fetchUserList();
employeeId.value = userStore.user?.id ?? null; userList.value = userListStore.userList;
await userListStore.fetchUserList(); //
userList.value = userListStore.userList; userList.value.forEach(user => {
userColors.value[user.MEMBERSEQ] = user.usercolor || "#ccc";
userList.value.forEach(user => {
userColors.value[user.MEMBERSEQ] = user.usercolor || "#ccc";
});
nextTick(() => {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(tooltip => {
new bootstrap.Tooltip(tooltip);
}); });
});
});
const sortedUserList = computed(() => { nextTick(() => {
if (!employeeId.value) return []; const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach(tooltip => {
new bootstrap.Tooltip(tooltip);
});
});
});
// ( ) const sortedUserList = computed(() => {
if (!employeeId.value) return userList.value; //
const myProfile = userList.value.find(user => user.MEMBERSEQ === employeeId.value); const myProfile = userList.value.find(user => user.MEMBERSEQ === employeeId.value);
const otherUsers = userList.value.filter(user => user.MEMBERSEQ !== employeeId.value); const otherUsers = userList.value.filter(user => user.MEMBERSEQ !== employeeId.value);
return myProfile ? [myProfile, ...otherUsers] : otherUsers; return myProfile ? [myProfile, ...otherUsers] : userList.value;
}); });
const getUserProfileImage = (profilePath) => // URL
profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile; const getUserProfileImage = (profilePath) => {
return profilePath && profilePath.trim()
? `${baseUrl}upload/img/profile/${profilePath}`
: defaultProfile;
};
const setDefaultImage = (event) => (event.target.src = defaultProfile); const setDefaultImage = (event) => {
const showImage = (event) => (event.target.style.visibility = "visible"); event.target.src = defaultProfile;
};
const getDynamicStyle = (user) => ({ const showImage = (event) => {
borderWidth: "3px", event.target.style.visibility = "visible";
borderColor: user.usercolor || "#ccc", };
borderStyle: "solid",
});
</script>
<style scoped> // :
.crown-icon { const profileSize = computed(() => {
width: 90%; const totalUsers = userList.value.length;
height: 70%; if (totalUsers <= 10) return "68px"; // ~10
z-index: 0; if (totalUsers <= 15) return "50px"; // ~20
top: -7% return "30px"; // 20
} });
</style>
//
const getDynamicStyle = (user) => {
return {
width: profileSize.value,
height: profileSize.value,
borderWidth: "4px",
borderColor: user.usercolor || "#ccc",
borderStyle: "solid",
};
};
</script>
<style scoped>
/* 남은 연차 개수 스타일 */
.remaining-vacation {
display: block;
text-align: center;
font-size: 14px;
color: #333;
margin-top: 5px;
}
/* ul에 flex-wrap을 적용하여 넘치는 프로필이 다음 줄로 내려가도록 함 */
ul {
flex-wrap: wrap;
justify-content: center;
}
/* li 간 간격 조정 */
li {
margin: 5px;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,7 +7,6 @@
<div class="layout-page"> <div class="layout-page">
<!-- Top --> <!-- Top -->
<TheTop /> <TheTop />
<!-- Content --> <!-- Content -->
<div class="content-wrapper"> <div class="content-wrapper">
<slot name="content"> body </slot> <slot name="content"> body </slot>
@ -15,7 +14,6 @@
<div class="content-backdrop fade"></div> <div class="content-backdrop fade"></div>
</div> </div>
</div> </div>
<TheChat />
</div> </div>
<!-- Overlay --> <!-- Overlay -->
@ -23,18 +21,14 @@
<!-- Drag Target Area To SlideIn Menu On Small Screens --> <!-- Drag Target Area To SlideIn Menu On Small Screens -->
<div class="drag-target"></div> <div class="drag-target"></div>
<ScrollTopButton />
</div> </div>
</template> </template>
<script setup> <script setup>
import TheTop from './TheTop.vue'; import TheTop from './TheTop.vue';
import TheFooter from './TheFooter.vue'; import TheFooter from './TheFooter.vue';
import TheMenu from './TheMenu.vue'; import TheMenu from './TheMenu.vue';
import TheChat from './TheChat.vue';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import { wait } from '@/common/utils'; import { wait } from '@/common/utils';
import ScrollTopButton from '@c/button/ScrollTopButton.vue';
window.isDarkStyle = window.Helpers.isDarkStyle(); window.isDarkStyle = window.Helpers.isDarkStyle();
@ -51,3 +45,4 @@
loadScript('/js/main.js'); loadScript('/js/main.js');
}); });
</script> </script>
<style></style>

View File

@ -1,32 +0,0 @@
<template>
<!-- Chat Sidebar -->
<aside id="chat-sidebar" class="chat-sidebar bg-white position-fixed top-0 end-0 z-4 vh-100 menu border shadow">
</aside>
</template>
<script setup>
import { ref } from "vue";
//
const messages = ref([
{ user: "사용자1", text: "안녕하세요!" },
{ user: "사용자2", text: "안녕하세요. 반갑습니다." }
]);
const newMessage = ref("");
//
const sendMessage = () => {
if (newMessage.value.trim() !== "") {
messages.value.push({ user: "나", text: newMessage.value });
newMessage.value = "";
}
};
</script>
<style scoped>
.chat-sidebar {
width: 20%;
}
</style>

View File

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

View File

@ -23,10 +23,9 @@
<span class="app-brand-text demo menu-text fw-bold ms-2">LOCALNET</span> <span class="app-brand-text demo menu-text fw-bold ms-2">LOCALNET</span>
</a> </a>
<!-- 메뉴 토글바 --> <a href="javascript:void(0);" class="layout-menu-toggle menu-link text-large ms-auto">
<!-- <a href="javascript:void(0);" class="layout-menu-toggle menu-link text-large ms-auto">
<i class="bx bx-chevron-left bx-sm d-flex align-items-center justify-content-center"></i> <i class="bx bx-chevron-left bx-sm d-flex align-items-center justify-content-center"></i>
</a> --> </a>
</div> </div>
<div class="menu-inner-shadow"></div> <div class="menu-inner-shadow"></div>
@ -74,32 +73,19 @@
<div class="text-truncate">Commuters</div> <div class="text-truncate">Commuters</div>
</RouterLink> </RouterLink>
</li> </li>
<li v-if="userId === allowedUserId" class="menu-item" :class="$route.path.includes('/authorization') ? 'active' : ''"> <!-- <li class="menu-item" :class="$route.path.includes('/sample') ? 'active' : ''">
<RouterLink class="menu-link" to="/authorization"> <RouterLink class="menu-link" to="/sample"> <i class="bi "></i>
<i class="menu-icon icon-base bx bx-user-check"></i> <i class="menu-icon tf-icons bx bx-calendar"></i>
<div class="text-truncate">Authorization</div> <div class="text-truncate">Sample</div>
</RouterLink> </RouterLink>
</li> </li> -->
<li class="menu-item" :class="$route.path.includes('/people') ? 'active' : ''">
<RouterLink class="menu-link" to="/people"> <i class="bi "></i>
<i class="menu-icon icon-base bi bi-people-fill"></i>
<div class="text-truncate">people</div>
</RouterLink>
</li>
</ul> </ul>
</aside> </aside>
<!-- / Menu --> <!-- / Menu -->
</template> </template>
<script setup> <script setup>
import { computed } from "vue"; import router from '@/router';
import { useUserInfoStore } from '@s/useUserInfoStore';
import "bootstrap-icons/font/bootstrap-icons.css";
const userStore = useUserInfoStore();
const allowedUserId = 1; // ID (!!)
const userId = computed(() => userStore.user?.id ?? null);
</script> </script>
<style lang="scss" scoped></style> <style lang="scss" scoped></style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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