Compare commits

..

1 Commits

Author SHA1 Message Date
aab5b5ab58 리스트 디자인 수정 2025-02-20 23:58:00 +09:00
142 changed files with 10171 additions and 17382 deletions

View File

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

View File

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

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
'''
}
}
}
}

844
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,8 +5,7 @@
"type": "module", "type": "module",
"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", "build": "vite build --mode prod",
"build": "vite build --mode dev",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"format": "prettier --write src/" "format": "prettier --write src/"
@ -19,7 +18,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",
@ -35,8 +33,7 @@
"vite-plugin-mkcert": "^1.17.6", "vite-plugin-mkcert": "^1.17.6",
"vue": "^3.5.13", "vue": "^3.5.13",
"vue-flatpickr-component": "^11.0.5", "vue-flatpickr-component": "^11.0.5",
"vue-router": "^4.4.5", "vue-router": "^4.4.5"
"vue3-kakao-maps": "^2.3.10"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.14.0", "@eslint/js": "^9.14.0",

View File

@ -1,2 +1,6 @@
/* 여기에 dark css 작성 */ /* 여기에 dark css 작성 */
.display-block {
display: block !important;
}

View File

@ -1,834 +1,89 @@
/* 여기에 light css 작성 */ /* 여기에 light css 작성 */
.opacity-50 {
opacity: 0.5; .display-block {
display: block !important;
} }
/* board */ /* 게시판리스트 */
.board-content img { .bg-label-gray td {
max-width: 100% !important; color: #DC3545 !important;
height: auto !important;
display: block;
object-fit: contain;
} }
.board-content table {
max-width: 100%;
overflow-x: auto;
display: block;
}
.btn.big { /* 휴가 */
width: 70px; .half-day-buttons {
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; display: flex;
justify-content: center; justify-content: center;
align-items: center; gap: 10px;
text-align: center; margin-top: 20px;
color: #000;
font-family: 'Poppins', sans-serif;
z-index: 9999 !important;
} }
/* 오류 메시지 컨텐츠 */ .half-day-buttons .btn.active {
.error-content { border: 2px solid black;
text-align: center;
animation: fadeIn 0.8s ease-in-out;
} }
/* 에러 이미지 */ .fc-daygrid-day-frame {
.error-image { min-height: 80px !important;
width: 280px; /* 이미지 크기 */ max-height: 120px !important;
margin-bottom: 20px; overflow: hidden !important;
padding-top: 25px !important;
} }
/* 에러 코드 스타일 */ .fc-daygrid-day-events {
.error-content h1 { max-height: 100px !important;
font-size: 6rem; overflow-y: auto !important;
font-weight: bold;
color: #ff8c00; /* 오렌지 */
text-shadow: 2px 2px 8px rgba(255, 140, 0, 0.3);
margin-bottom: 60px;
} }
/* 홈으로 돌아가기 버튼 */
.home-btn {
display: inline-block;
padding: 10px 28px;
font-size: 1rem;
font-weight: bold;
text-decoration: none;
color: #fff;
background: rgba(105, 108, 255, 0.9);
border-radius: 30px;
transition: 0.3s ease-in-out;
box-shadow: 0 4px 15px rgba(105, 108, 255, 0.5);
}
/* 버튼 호버 효과 */
.home-btn:hover {
background: linear-gradient(90deg, orange, #ff8c00);
box-shadow: 0 0 20px rgba(255, 140, 0, 1);
transform: scale(1.05);
color: #fff;
}
/* 페이드 인 애니메이션 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 휴가 */
.fc-daygrid-event { .fc-daygrid-event {
pointer-events: none !important; position: absolute !important;
height: 20px !important;
width: 100% !important;
left: 0 !important;
margin: 2px 0 !important;
padding: 0 !important;
border-radius: 2px !important;
border: none !important;
} }
/* 이벤트 선 없게 */ .fc-daygrid-event-harness {
.fc-event { display: flex;
border: none; flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
gap: 22px;
} }
/* 오전 반차 그래프 (왼쪽 절반) */
.fc-daygrid-event.half-day-am { .fc-daygrid-event.half-day-am {
width: 50% !important; width: 45% !important;
height: 8px !important; left: 0 !important;
border-radius: 2px !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: 45% !important;
height: 8px !important; left: auto !important;
margin-left: auto !important; right: 0 !important;
border-radius: 2px !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; left: 0 !important;
margin-left: auto !important;
border-radius: 2px !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; position: absolute !important;
} top: 0px !important;
/* 데이트피커 뾰족없게 */ left: 5px !important;
.flatpickr-calendar:before, text-align: left !important;
.flatpickr-calendar:after {
display: none !important;
}
/* 기본 스타일은 그대로 두고, 데이트피커 인풋의 추가 스타일 정의 */
.fc-toolbar-title {
cursor: pointer;
}
.fc-today-button {
cursor: pointer !important;
}
/* 클릭 가능한 날짜 */
.fc-daygrid-day.clickable {
cursor: pointer;
transition: background-color 0.2s ease-in-out;
}
/* 마우스를 올렸을 때 효과 */
.fc-daygrid-day.clickable:hover {
background-color: rgba(0, 0, 0, 0.05); /* 연한 배경 효과 */
}
/* 주말 (토요일, 일요일) 및 공휴일 */
.fc-day-sat-sun {
cursor: not-allowed !important;
}
/* 과거 날짜 (오늘 -7일일) */
.fc-daygrid-day.past {
cursor: not-allowed !important;
}
/* 기본 이벤트 스타일 */
.fc-daygrid-event {
border: none !important;
border-radius: 4px;
}
/* 오전 반차 활성화 영역 (왼쪽 절반) */
.selected-event.half-day-am {
width: 50% !important;
left: 0 !important;
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
}
/* 오후 반차 활성화 영역 (오른쪽 절반) */
.selected-event.half-day-pm {
width: 50% !important;
margin-left: auto !important;
border-top-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 {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
}
.close-btn:hover {
color: #525252;
}
.count-btn {
font-size: 17px;
padding: 2px 10px;
border: none;
background: #2c3e50;
color: white;
border-radius: 5px;
cursor: pointer;
}
.count-btn:hover {
background: #1d2c44;
}
.count-btn:disabled {
background: #cccccc;
cursor: not-allowed;
}
.custom-button-container {
display: flex;
justify-content: flex-end;
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 {
transition: all 0.2sease-in-out;
border: 2px solid transparent;
}
/* 마우스를 올렸을 때 */
.vac-btn:hover {
filter: brightness(90%);
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.2);
transform: scale(1.05);
}
/* 버튼이 눌렸을 때 */
.vac-btn:active {
transform: scale(0.9);
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 */
.hidden-start-input {
position: absolute;
top: 103%;
left: 20%;
width: 100%;
height: 40px;
opacity: 0;
}
.hidden-end-input {
position: absolute;
top: 113.3%;
left: 20%;
width: 100%;
height: 40px;
opacity: 0;
}
.map {
top: -160px;
left: 90px;
}
@keyframes sparkle {
0% {
color: #ffcc00;
}
50% {
color: red;
}
100% {
color: #ffcc00;
}
}
.bxs-map {
animation: sparkle 1s infinite; /* 1초마다 반복 */
}
.popover-close {
position: absolute;
top: 1rem;
right: 1rem;
z-index: 2;
background-color: #fff !important;
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 {
opacity: 1;
outline: none;
transform: translate(20px, -20px);
}
.end-project {
background-color: #ddd !important;
}
/* 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View File

@ -2,116 +2,116 @@
* Main * Main
*/ */
(function () { 'use strict'
let menu, animate
;(function () {
// Initialize menu // Initialize menu
//----------------- //-----------------
let menu, animate;
let layoutMenuEl = document.querySelectorAll('#layout-menu'); let layoutMenuEl = document.querySelectorAll('#layout-menu')
layoutMenuEl.forEach(function (element) { layoutMenuEl.forEach(function (element) {
menu = new Menu(element, { menu = new Menu(element, {
orientation: 'vertical', orientation: 'vertical',
closeChildren: false, closeChildren: false,
}); })
// Change parameter to true if you want scroll animation // Change parameter to true if you want scroll animation
window.Helpers.scrollToActive((animate = false)); window.Helpers.scrollToActive((animate = false))
window.Helpers.mainMenu = menu; window.Helpers.mainMenu = menu
}); })
// Initialize menu togglers and bind click on each // Initialize menu togglers and bind click on each
let menuToggler = document.querySelectorAll('.layout-menu-toggle'); let menuToggler = document.querySelectorAll('.layout-menu-toggle')
menuToggler.forEach(item => { menuToggler.forEach((item) => {
item.addEventListener('click', event => { item.addEventListener('click', (event) => {
event.preventDefault(); event.preventDefault()
window.Helpers.toggleCollapsed(); window.Helpers.toggleCollapsed()
}); })
}); })
// Display menu toggle (layout-menu-toggle) on hover with delay // Display menu toggle (layout-menu-toggle) on hover with delay
let delay = function (elem, callback) { let delay = function (elem, callback) {
let timeout = null; let timeout = null
elem.onmouseenter = function () { elem.onmouseenter = function () {
// Set timeout to be a timer which will invoke callback after 300ms (not for small screen) // Set timeout to be a timer which will invoke callback after 300ms (not for small screen)
if (!Helpers.isSmallScreen()) { if (!Helpers.isSmallScreen()) {
timeout = setTimeout(callback, 300); timeout = setTimeout(callback, 300)
} else { } else {
timeout = setTimeout(callback, 0); timeout = setTimeout(callback, 0)
}
} }
};
elem.onmouseleave = function () { elem.onmouseleave = function () {
// Clear any timers set to timeout // Clear any timers set to timeout
document.querySelector('.layout-menu-toggle').classList.remove('d-block'); document.querySelector('.layout-menu-toggle').classList.remove('d-block')
clearTimeout(timeout); clearTimeout(timeout)
}; }
}; }
if (document.getElementById('layout-menu')) { if (document.getElementById('layout-menu')) {
delay(document.getElementById('layout-menu'), function () { delay(document.getElementById('layout-menu'), function () {
// not for small screen // not for small screen
if (!Helpers.isSmallScreen()) { if (!Helpers.isSmallScreen()) {
document.querySelector('.layout-menu-toggle').classList.add('d-block'); document.querySelector('.layout-menu-toggle').classList.add('d-block')
} }
}); })
} }
// Display in main menu when menu scrolls // Display in main menu when menu scrolls
let menuInnerContainer = document.getElementsByClassName('menu-inner'), let menuInnerContainer = document.getElementsByClassName('menu-inner'),
menuInnerShadow = document.getElementsByClassName('menu-inner-shadow')[0]; menuInnerShadow = document.getElementsByClassName('menu-inner-shadow')[0]
if (menuInnerContainer.length > 0 && menuInnerShadow) { if (menuInnerContainer.length > 0 && menuInnerShadow) {
menuInnerContainer[0].addEventListener('ps-scroll-y', function () { menuInnerContainer[0].addEventListener('ps-scroll-y', function () {
if (this.querySelector('.ps__thumb-y').offsetTop) { if (this.querySelector('.ps__thumb-y').offsetTop) {
menuInnerShadow.style.display = 'block'; menuInnerShadow.style.display = 'block'
} else { } else {
menuInnerShadow.style.display = 'none'; menuInnerShadow.style.display = 'none'
} }
}); })
} }
// Init helpers & misc // Init helpers & misc
// -------------------- // --------------------
// Init BS Tooltip // Init BS Tooltip
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')); const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
tooltipTriggerList.map(function (tooltipTriggerEl) { tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl); return new bootstrap.Tooltip(tooltipTriggerEl)
}); })
// Accordion active class // Accordion active class
const accordionActiveFunction = function (e) { const accordionActiveFunction = function (e) {
if (e.type == 'show.bs.collapse' || e.type == 'show.bs.collapse') { if (e.type == 'show.bs.collapse' || e.type == 'show.bs.collapse') {
e.target.closest('.accordion-item').classList.add('active'); e.target.closest('.accordion-item').classList.add('active')
} else { } else {
e.target.closest('.accordion-item').classList.remove('active'); e.target.closest('.accordion-item').classList.remove('active')
}
} }
};
const accordionTriggerList = [].slice.call(document.querySelectorAll('.accordion')); const accordionTriggerList = [].slice.call(document.querySelectorAll('.accordion'))
const accordionList = accordionTriggerList.map(function (accordionTriggerEl) { const accordionList = accordionTriggerList.map(function (accordionTriggerEl) {
accordionTriggerEl.addEventListener('show.bs.collapse', accordionActiveFunction); accordionTriggerEl.addEventListener('show.bs.collapse', accordionActiveFunction)
accordionTriggerEl.addEventListener('hide.bs.collapse', accordionActiveFunction); accordionTriggerEl.addEventListener('hide.bs.collapse', accordionActiveFunction)
}); })
// Auto update layout based on screen size // Auto update layout based on screen size
window.Helpers.setAutoUpdate(true); window.Helpers.setAutoUpdate(true)
// Toggle Password Visibility // Toggle Password Visibility
window.Helpers.initPasswordToggle(); window.Helpers.initPasswordToggle()
// Speech To Text // Speech To Text
window.Helpers.initSpeechToText(); window.Helpers.initSpeechToText()
// Manage menu expanded/collapsed with templateCustomizer & local storage // Manage menu expanded/collapsed with templateCustomizer & local storage
//------------------------------------------------------------------ //------------------------------------------------------------------
// If current layout is horizontal OR current window screen is small (overlay menu) than return from here // If current layout is horizontal OR current window screen is small (overlay menu) than return from here
if (window.Helpers.isSmallScreen()) { if (window.Helpers.isSmallScreen()) {
return; return
} }
// 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

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,10 +1,9 @@
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: 'https://192.168.0.251:10325/api/',
timeout: 300000, timeout: 300000,
withCredentials: true, withCredentials: true,
}); });
@ -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,39 @@ $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();
const currentPage = error.config.headers['X-Page-Route'];
// 로그인 요청 별도 처리 (헤더에 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:
if (currentPage === '/login') {
toastStore.onToast('아이디 혹은 비밀번호가 틀렸습니다.', 'e');
} else {
toastStore.onToast('인증이 필요합니다.', 'e'); toastStore.onToast('인증이 필요합니다.', 'e');
router.push('/login'); }
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 +72,4 @@ $api.interceptors.response.use(
}, },
); );
export default $api; export default $api;

View File

@ -1,8 +1,8 @@
/* /*
작성자 : 공현지 작성자 : 공현지
작성일 : 2025-01-17 작성일 : 2025-01-17
수정자 : 박성용 수정자 :
수정일 : 2025-03-11 수정일 :
설명 : 공통 스크립트 설명 : 공통 스크립트
*/ */
import Quill from 'quill'; import Quill from 'quill';
@ -12,9 +12,7 @@ import Quill from 'quill';
*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 타입을 변환
@ -40,182 +38,12 @@ const common = {
} }
console.error('잘못된 Delta 객체:', content); console.error('잘못된 Delta 객체:', content);
return null; // Delta 객체가 아니거나 ops가 없을 경우 null 반환 return null; // Delta 객체가 아니거나 ops가 없을 경우 null 반환
},
/**
* Date 타입 문자열 포멧팅
*
* @param {string} dateStr
* @return
* 1. Date type 경우 예시 '2025-02-24 12:02'
* 2. Date type 아닌 경우 입력값 리턴
*
*/
dateFormatter(dateStr, type = null) {
const date = new Date(dateStr);
const dateCheck = date.getTime();
if (isNaN(dateCheck)) {
return dateStr;
} else {
const { year, month, day, hours, minutes } = this.formatDateTime(date);
let callback = '';
if (type == 'YMD') {
callback = `${year}-${month}-${day}`;
} else if (type == 'MD') {
callback = `${month}-${day}`;
} else if (type == 'T') {
callback = `${hours}:${minutes}`;
} else {
callback = `${year}-${month}-${day} ${hours}:${minutes}`;
} }
return callback;
} }
},
formatDateTime(dateStr) {
const date = new Date(dateStr);
const dateCheck = date.getTime();
if (isNaN(dateCheck)) return dateStr;
const zeroFormat = num => (num < 10 ? `0${num}` : num);
return {
year: date.getFullYear(),
month: zeroFormat(date.getMonth() + 1),
day: zeroFormat(date.getDate()),
hours: zeroFormat(date.getHours()),
minutes: zeroFormat(date.getMinutes()),
seconds: zeroFormat(date.getSeconds()),
};
},
// 오늘 날짜시간 조회
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) {
if (obj === null || obj === undefined) {
return false;
}
if (typeof obj === 'string' && obj.trim() === '') {
return false;
}
if ((Array.isArray(obj) || obj === Object(obj)) && Object.keys(obj).length === 0) {
return false;
}
return true;
},
/**
* 에디터에 내용이 있는지 확인
*
* @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 {
install(app) { install(app) {
app.config.globalProperties.$common = common; app.config.globalProperties.$common = common;
app.provide('common', common); }
},
}; };

View File

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

View File

@ -1,13 +0,0 @@
/** 날짜 포맷1 (YYYY-MM-DD HH:MM) */
export const formattedDate = (dateString) => {
if (!dateString) return "날짜 없음";
const dateObj = new Date(dateString);
return `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')} ${String(dateObj.getHours()).padStart(2, '0')}:${String(dateObj.getMinutes()).padStart(2, '0')}`;
};
/** 날짜 포맷2 (YYYY-MM-DD) */
export const formatDate = (dateString) => {
if (!dateString) return "날짜 없음";
const dateObj = new Date(dateString);
return `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
};

View File

@ -1,74 +1,75 @@
<template> <template>
<div> <div>
<BoardProfile <BoardProfile
:unknown="comment.author === '익명'" :unknown="unknown"
: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"
:author="true"
:isLike="!isLike" :isLike="!isLike"
:isCommentPassword="isCommentPassword" :isPassword="isPassword"
:isCommentProfile="true" @editClick="editClick"
:is-edit-pushed="isEditPushed" @deleteClick="deleteClick"
:is-delete-pushed="isDeletePushed" @submitPassword="submitPassword"
@editClick="handleEditClick"
@deleteClick="$emit('deleteClick', comment)"
@updateReaction="handleUpdateReaction" @updateReaction="handleUpdateReaction"
@toggleEdit="emit('toggleEdit', comment.commentId, true)"
/> />
<!-- 댓글 비밀번호 입력창 (익명일 경우) -->
<div v-if="currentPasswordCommentId === comment.commentId && unknown && comment.author == '익명'" class="mt-3 w-px-200 ms-auto">
<div class="input-group">
<input
type="password"
class="form-control"
:value="password"
autocomplete="new-password"
maxlength="8"
placeholder="비밀번호 입력"
@input="filterInput"
/>
<button class="btn btn-primary" @click="logPasswordAndEmit">확인</button>
</div>
<span v-if="passwordCommentAlert" class="invalid-feedback d-block text-start">{{ passwordCommentAlert }}</span>
</div>
<div class="mt-6"> <div class="mt-6">
<template v-if="comment.isEditTextarea"> <template v-if="isEditTextarea">
<textarea v-model="localEditedContent" class="form-control" maxLength="500"></textarea> <textarea v-model="editedContent" 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> <button class="btn btn-secondary me-2" @click="emit('toggleEdit', comment.commentId, false)">취소</button>
<button class="btn btn-primary" @click="submitEdit">수정 완료</button>
</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" @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="unknown"
:isPlusButton="false"
:isLike="true"
@submitComment="submitComment"
@updateReaction="handleUpdateReaction"
/>
</li>
</ul>
<!-- <ul class="list-unstyled twoDepth">
<li>
<BoardProfile profileName=곤데리2 :showDetail="false" />
<div class="mt-2">저도 궁금합니다.</div>
<BoardCommentArea v-if="comment" />
</li>
</ul> -->
<!-- <BoardProfile profileName=곤데리 :showDetail="false" />
<div class="mt-2">저도 궁금합니다.</div>
<PlusButton @click="toggleComment"/>
<BoardCommentArea v-if="comment" /> -->
</div> </div>
</template> </template>
<script setup> <script setup>
import { defineProps, defineEmits, ref, computed, watch, inject } from 'vue'; import { defineProps, defineEmits, ref } 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';
const props = defineProps({ const props = defineProps({
comment: { comment: {
@ -77,17 +78,8 @@
}, },
unknown: { unknown: {
type: Boolean, type: Boolean,
default: false, default: true,
}, },
nickname: {
type: String,
default: '',
},
isCommentAuthor: {
type: Boolean,
default: false,
},
isPlusButton: { isPlusButton: {
type: Boolean, type: Boolean,
default: true, default: true,
@ -98,65 +90,16 @@
}, },
isEditTextarea: { isEditTextarea: {
type: Boolean, type: Boolean,
default: false, default: false
}, },
isDeleted: { isPassword: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isCommentPassword: {
type: Boolean,
default: false,
},
passwordCommentAlert: {
type: String,
default: '',
},
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;
}); });
// emits // emits
const emit = defineEmits([ const emit = defineEmits(['submitComment', 'updateReaction', 'toggleEdit', 'editClick']);
'submitComment',
'updateReaction',
'editClick',
'deleteClick',
'submitPassword',
'submitEdit',
'cancelEdit',
'update:password',
'inputDetector',
]);
const filterInput = event => {
event.target.value = event.target.value.replace(/\s/g, ''); //
emit('update:password', event.target.value);
};
const localEditedContent = ref(props.comment.content);
const isModifyContent = ref(props.comment.content);
const disabled = ref(false);
// //
const isComment = ref(false); const isComment = ref(false);
@ -165,80 +108,31 @@
}; };
// //
const submitComment = newComment => { const submitComment = (newComment) => {
emit('submitComment', { parentId: props.comment.commentId, ...newComment, LOCBRDTYP: newComment.LOCBRDTYP }); emit('submitComment', { parentId: props.comment.commentId, ...newComment });
isComment.value = false; isComment.value = false;
}; };
// , // ,
const handleUpdateReaction = reactionData => { const handleUpdateReaction = (reactionData) => {
emit('updateReaction', { emit('updateReaction', {
boardId: props.comment.boardId, boardId: props.comment.boardId,
commentId: props.comment.commentId || reactionData.commentId, commentId: props.comment.commentId,
...reactionData, ...reactionData
}); });
}; };
// //
const logPasswordAndEmit = () => { const editClick = (data) => {
emit('submitPassword', props.comment, props.password); emit('editClick', data);
}; };
const handleInject = inject('isBtnPushed'); //
const editedContent = ref(props.comment.content);
// ,
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 = () => { const submitEdit = () => {
emit('submitEdit', props.comment, localEditedContent.value); emit('submitComment', { commentId: props.comment.commentId, content: editedContent.value });
emit('toggleEdit', props.comment.commentId, false); //
}; };
const handleEditClick = () => {
emit('editClick', props.comment);
};
</script> </script>

View File

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

View File

@ -1,176 +1,67 @@
<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" :isPassword="isPassword"
:isEditTextarea="comment.isEditTextarea" @editClick="editClick"
:isDeleted="isDeleted" @deleteClick="deleteClick"
:nickname="comment.nickname"
:isCommentPassword="isCommentPassword"
:passwordCommentAlert="passwordCommentAlert || ''"
:currentPasswordCommentId="currentPasswordCommentId"
:password="password"
:editCommentAlert="editCommentAlert[comment.commentId]"
:is-edit-pushed="comment.isEditPushed"
:is-delete-pushed="comment.isDeletePushed"
@editClick="handleEditClick"
@deleteClick="handleDeleteClick"
@submitPassword="submitPassword" @submitPassword="submitPassword"
@submitComment="submitComment" @submitComment="submitComment"
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent)" @updateReaction="(reactionData) => handleUpdateReaction(reactionData, comment.commentId)"
@cancelEdit="handleCancelEdit"
@updateReaction="reactionData => handleUpdateReaction(reactionData, comment.commentId, comment.boardId)"
@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> <!-- @updateReaction="handleUpdateReaction" -->
</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: { isPassword: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isCommentPassword: {
type: Boolean,
default: false,
},
isEditTextarea: {
type: Boolean,
default: false,
},
isDeleted: {
type: Boolean,
default: false,
},
passwordCommentAlert: {
type: String,
default: '',
},
currentPasswordCommentId: {
type: Number,
},
password: {
type: String,
},
index: {
type: Number,
},
editCommentAlert: Object,
}); });
const emit = defineEmits([ const emit = defineEmits(['submitComment', 'updateReaction', 'editClick']);
'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 handleUpdateReaction = (reactionData, commentId) => {
// console.log('📢 BoardCommentList :', reactionData);
// console.log('📌 ID>>>>:', commentId);
const updatedReactionData = { const updatedReactionData = {
...reactionData, ...reactionData,
commentId: commentId || reactionData.commentId, commentId: commentId
boardId: boardId || reactionData.boardId,
}; };
// console.log('🚀 :', updatedReactionData);
emit('updateReaction', updatedReactionData); emit('updateReaction', updatedReactionData);
};
const submitPassword = (comment, password) => {
emit('submitPassword', comment, password);
};
const handleEditClick = comment => {
if (comment.parentId) {
emit('editClick', comment); //
} else {
emit('editClick', comment); //
} }
const editClick = (data) => {
emit('editClick', data);
}; };
const handleSubmitEdit = (comment, editedContent) => {
emit('submitEdit', comment, editedContent);
};
const handleDeleteClick = comment => {
if (comment.parentId) {
emit('deleteClick', comment); //
} else {
emit('deleteClick', comment); //
}
};
const handleCancelEdit = comment => {
if (comment.parentId) {
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,20 @@
<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,47 +22,54 @@
<!-- 버튼 영역 --> <!-- 버튼 영역 -->
<div class="ms-auto text-end"> <div class="ms-auto text-end">
<!-- 수정, 삭제 버튼 --> <!-- 수정, 삭제 버튼 -->
<template v-if="!isDeletedComment && (unknown || isCommentAuthor || isAuthor)"> <template v-if="author || showDetail">
<div class="float-end ms-1"> <EditButton @click.stop="editClick" />
<slot name="gobackBtn"></slot> <DeleteButton @click.stop="deleteClick" />
<EditButton @click.stop="editClick" :is-pushed="isEditPushed" />
<DeleteButton :class="'ms-1'" @click.stop="deleteClick" :is-pushed="isDeletePushed" />
</div>
</template>
<template v-else>
<div class="float-end ms-1">
<slot name="gobackBtn"></slot>
</div>
</template> </template>
<!-- 좋아요, 싫어요 버튼 (댓글에서만 표시) --> <!-- 좋아요, 싫어요 버튼 (댓글에서만 표시) -->
<BoardRecommendBtn <BoardRecommendBtn
v-if="isLike && !isDeletedComment" v-if="isLike"
:boardId="boardId" :boardId="boardId"
:comment="comment" :comment="props.comment"
:likeClicked="comment.likeClicked"
:dislikeClicked="comment.dislikeClicked"
@updateReaction="handleUpdateReaction" @updateReaction="handleUpdateReaction"
/> />
<!-- 비밀번호 입력창 (익명일 경우) -->
<div v-if="isPassword && unknown" class="mt-3">
<div class="input-group">
<input
type="password"
class="form-control"
v-model="password"
placeholder="비밀번호 입력"
/>
<button class="btn btn-primary" @click="$emit('submitPassword', password)">확인</button>
</div>
<span v-if="props.passwordAlert" class="invalid-feedback d-block text-start">{{ props.passwordAlert }}</span>
</div>
</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';
// Vue Router
const password = ref('');
// Props // Props
const props = defineProps({ const props = defineProps({
comment: { comment: {
type: Object, type: Object,
required: false, required: true,
}, },
boardId: { boardId: {
type: Number, type: Number,
required: false, required: false
}, },
commentId: { commentId: {
type: Number, type: Number,
@ -73,11 +77,7 @@
}, },
profileName: { profileName: {
type: String, type: String,
default: '', default: '익명 사용자',
},
nickname: {
type: String,
default: '',
}, },
unknown: { unknown: {
type: Boolean, type: Boolean,
@ -87,15 +87,14 @@
type: Boolean, type: Boolean,
default: true, default: true,
}, },
isAuthor: { // :
author: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isCommentAuthor: Boolean,
isCommentProfile: Boolean,
date: { date: {
type: String, type: String,
required: '', required: true,
}, },
views: { views: {
type: Number, type: Number,
@ -109,56 +108,65 @@
type: Boolean, type: Boolean,
default: false, default: false,
}, },
profileImg: { isPassword: {
type: String, type: Boolean,
default: false, default: false,
}, },
isEditPushed: { passwordAlert: {
type: Boolean, type: String,
require: false, default: false,
}, }
isDeletePushed: {
type: Boolean,
require: false,
},
}); });
const emit = defineEmits(['updateReaction', 'editClick', 'deleteClick']); const emit = defineEmits(['togglePasswordInput', 'updateReaction', 'editClick', 'deleteClick', 'updatePasswordAlert']);
const $common = inject('common');
const isDeletedComment = computed(() => {
return props.comment?.content === '삭제된 댓글입니다' && props.comment?.updateAtRaw !== props.comment?.createdAtRaw;
});
// //
const editClick = () => { const editClick = () => {
emit('editClick', { ...props.comment, unknown: props.unknown }); emit('editClick', props.unknown);
}; };
// //
const deleteClick = () => { const deleteClick = () => {
emit('deleteClick', { ...props.comment, unknown: props.unknown }); emit('deleteClick', props.unknown);
}; };
// / const handleUpdateReaction = (reactionData) => {
const handleUpdateReaction = reactionData => { // console.log("🔥 BoardProfile / ");
emit('updateReaction', { // console.log("📌 ID:", props.boardId);
// console.log("📌 ID ( ):", props.comment?.commentId);
// console.log("📌 reactionData:", reactionData);
emit("updateReaction", {
boardId: props.boardId, boardId: props.boardId,
commentId: props.comment?.commentId, commentId: props.comment?.commentId,
...reactionData, ...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,28 +1,16 @@
<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, watch } from 'vue';
const props = defineProps({ const props = defineProps({
comment: {
type: Object,
default: () => ({}),
},
likeClicked : { likeClicked : {
type : Boolean, type : Boolean,
default : false, default : false,
@ -44,7 +32,7 @@
required: true, required: true,
}, },
commentId: { commentId: {
type: [Number, null], type: Number,
default: null, default: null,
}, },
likeCount: { likeCount: {
@ -61,18 +49,21 @@
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 = ref(props.likeCount);
const dislikeCount = computed(() => props.comment?.dislikeCount ?? props.dislikeCount); const dislikeCount = ref(props.dislikeCount);
watch([() => props.likeClicked, () => props.dislikeClicked], ([likeNewval, dislikeNewval]) => { // likeCount dislikeCount
likeClicked.value = likeNewval; watch(() => props.likeCount, (newVal) => {
dislikeClicked.value = dislikeNewval; likeCount.value = newVal;
});
watch(() => props.dislikeCount, (newVal) => {
dislikeCount.value = newVal;
}); });
const handleLike = () => { const handleLike = () => {
const isLike = !likeClicked.value; const isLike = !likeClicked.value;
const isDislike = false; const isDislike = false;
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike }); emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
likeClicked.value = isLike; likeClicked.value = isLike;
dislikeClicked.value = false; dislikeClicked.value = false;
@ -81,9 +72,56 @@
const handleDislike = () => { const handleDislike = () => {
const isDislike = !dislikeClicked.value; const isDislike = !dislikeClicked.value;
const isLike = false; const isLike = false;
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike }); emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
dislikeClicked.value = isDislike; dislikeClicked.value = isDislike;
likeClicked.value = false; likeClicked.value = false;
}; };
</script> </script>
<style scoped>
.btn + .btn {
margin-left: 5px;
}
.num {
margin-left: 5px;
}
.btn-label-danger.clicked {
background-color: #e6381a;
}
.btn-label-danger.clicked i,
.btn-label-danger.clicked span {
color: #fff;
}
.btn-label-primary.clicked {
background-color: #5f61e6;
}
.btn-label-primary.clicked i,
.btn-label-primary.clicked span {
color : #fff;
}
.btn {
width: 55px;
height: 30px;
}
.btn.big {
width: 70px;
height: 70px;
font-size: 18px;
}
@media screen and (max-width:450px) {
.btn {
width: 50px;
height: 20px;
font-size: 12px;
}
}
</style>

View File

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

View File

@ -1,38 +1,13 @@
<template> <template>
<button class="btn btn-label-primary btn-icon" :class="{ active: props.isPushed }" @click="toggleText"> <button class="btn btn-label-primary btn-icon">
<i :class="buttonClass"></i> <i class="bx bx-edit-alt"></i>
</button> </button>
</template> </template>
<script setup>
import { ref, watch, defineEmits, watchEffect } from 'vue'; <script>
const props = defineProps({ export default {
isToggleEnabled: { name: 'EditButton',
type: Boolean, methods: {
default: false,
}, },
isActive: {
type: Boolean,
required: false,
},
isPushed: {
type: Boolean,
required: false,
},
});
const emit = defineEmits(['click']);
const buttonClass = ref('bx bx-edit-alt');
watchEffect(() => {
buttonClass.value = props.isActive ? 'bx bx-x' : 'bx bx-edit-alt';
});
const toggleText = event => {
//
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,56 +1,31 @@
<template> <template>
<div class="row gx-2 mb-10 mt-1"> <div class="half-day-buttons">
<div class="col-3"> <button
<div class="ratio ratio-1x1"> class="btn btn-info"
<!-- 오전 반차 버튼 -->
<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> <button
</div> class="btn btn-warning"
<div class="col-3">
<div class="ratio ratio-1x1">
<!-- 오후 반차 버튼 -->
<button class="vac-btn vac-btn-info rounded-circle d-flex align-items-center justify-content-center"
:class="{ active: halfDayType === 'PM' }" :class="{ active: halfDayType === 'PM' }"
@click="toggleHalfDay('PM')"> @click="toggleHalfDay('PM')"
<i class="bi bi-moon d-flex"></i> >
<i class="bi bi-moon"></i>
</button> </button>
</div> <div class="save-button-container">
</div> <button class="btn btn-success" @click="addVacationRequests">
<div class="col-3">
<div class="ratio ratio-1x1">
<!-- 연차 버튼 -->
<button class="vac-btn vac-btn-primary rounded-circle d-flex align-items-center justify-content-center"
:class="{ active: halfDayType === 'FULL' }"
@click="toggleHalfDay('FULL')">
<i class="bi bi-calendar d-flex"></i>
</button>
</div>
</div>
<div class="col-3">
<div class="ratio ratio-1x1">
<!-- 저장 버튼 -->
<button class="vac-btn-success rounded-circle d-flex align-items-center justify-content-center"
@click="addVacationRequests"
:class="{ active: !isDisabled, disabled: isDisabled }">
</button> </button>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { defineEmits, ref, defineProps } from "vue"; import { defineEmits, ref } from "vue";
const props = defineProps({ const emit = defineEmits(["toggleHalfDay", "addVacationRequests"]);
isDisabled: Boolean
});
const emit = defineEmits(["toggleHalfDay", "addVacationRequests", "resetHalfDay"]);
const halfDayType = ref(null); const halfDayType = ref(null);
const toggleHalfDay = (type) => { const toggleHalfDay = (type) => {
@ -58,15 +33,11 @@ const toggleHalfDay = (type) => {
emit("toggleHalfDay", halfDayType.value); emit("toggleHalfDay", halfDayType.value);
}; };
//
const resetHalfDay = () => {
halfDayType.value = null;
emit("resetHalfDay");
};
const addVacationRequests = () => { const addVacationRequests = () => {
emit("addVacationRequests"); emit("addVacationRequests");
}; };
defineExpose({ resetHalfDay });
</script> </script>
<style scoped>
</style>

View File

@ -1,14 +1,13 @@
<template> <template>
<button class="btn btn-label-primary btn-icon"> <button class="btn btn-label-primary btn-icon">
<slot>
<i class="icon-base bx bx-plus"></i> <i class="icon-base bx bx-plus"></i>
</slot>
</button> </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

@ -1,41 +1,13 @@
<template> <template>
<button class="btn btn-label-primary btn-icon float-end" @click="toggleText"> <button class="btn btn-label-primary btn-icon float-end">
<i :class="buttonClass"></i> <i class="bx bx-edit"></i>
</button> </button>
</template> </template>
<script setup> <script>
import { ref, defineProps, defineExpose, watch } from 'vue'; export default {
name: 'WriteButton',
const props = defineProps({ methods: {
isToggleEnabled: {
type: Boolean,
default: false,
}, },
isActive: {
type: Boolean,
required: false,
},
});
const buttonClass = ref("bx bx-edit");
watch(
() => props.isActive,
newVal => {
buttonClass.value = newVal ? 'bx bx-x' : 'bx bx-edit';
},
);
const toggleText = () => {
if (props.isToggleEnabled) {
buttonClass.value = buttonClass.value === "bx bx-edit" ? "bx bx-x" : "bx bx-edit";
}
}; };
const resetButton = () => {
buttonClass.value = "bx bx-edit";
};
defineExpose({ resetButton });
</script> </script>

View File

@ -1,18 +0,0 @@
// src/api/holiday.js
import axios from "@api";
export async function fetchHolidays(year, month) {
try {
const response = await axios.get(`vacation/${year}/${month}`);
const holidayEvents = response.data.map((holiday) => ({
title: holiday.name,
start: holiday.date, // "YYYY-MM-DD" 형식
backgroundColor: "#ff6666",
classNames: ["holiday-event"],
}));
return holidayEvents;
} catch (error) {
console.error("공휴일 정보를 불러오지 못했습니다.", error);
return [];
}
}

View File

@ -1,25 +1,12 @@
<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)"
> >
@ -27,39 +14,41 @@
</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

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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,6 @@
type="text" type="text"
v-model="postcode" v-model="postcode"
placeholder="우편번호" placeholder="우편번호"
disabled="true"
readonly readonly
/> />
@ -27,9 +26,9 @@
type="text" type="text"
v-model="address" v-model="address"
placeholder="기본주소" placeholder="기본주소"
disabled="true"
readonly readonly
/> />
</div> </div>
<div> <div>
@ -88,11 +87,7 @@ const props = defineProps({
}, },
modelValue: { modelValue: {
type: Object, type: Object,
default: () => ({ default: () => ({}),
postcode: '',
address: '',
detailAddress: ''
}),
required: false required: false
} }
}); });
@ -100,13 +95,13 @@ const props = defineProps({
// watch // watch
watch(() => props.modelValue, (newValue) => { watch(() => props.modelValue, (newValue) => {
if (newValue) { if (newValue) {
postcode.value = newValue.postcode || ''; postcode.value = newValue.PROJCTZIP || '';
address.value = newValue.address || ''; address.value = newValue.PROJCTARR || '';
detailAddress.value = newValue.detailAddress || ''; detailAddress.value = newValue.PROJCTDTL || '';
} }
}, { immediate: true }); }, { immediate: true });
const emits = defineEmits(['update:data', 'update:alert', 'update:modelValue']); const emits = defineEmits(['update:data', 'update:alert']);
// //
const openAddressSearch = () => { const openAddressSearch = () => {
@ -141,7 +136,6 @@ const emitAddressData = () => {
detailAddress: detailAddress.value, detailAddress: detailAddress.value,
}; };
emits('update:data', fullAddress); emits('update:data', fullAddress);
emits('update:modelValue', fullAddress); // modelValue
}; };
// isAlert false // isAlert false

View File

@ -1,15 +1,11 @@
<template> <template>
<div class="mb-4 row"> <div class="mb-4 row">
<label :for="inputId" class="col-md-2 col-form-label">{{ title }}</label> <label :for="name" 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="name"
:id="inputId"
ref="fileInput"
:key="autoIncrement"
@change="changeHandler" @change="changeHandler"
multiple multiple
/> />
@ -25,7 +21,7 @@
import { fileMsg } from '@/common/msgEnum'; import { fileMsg } from '@/common/msgEnum';
// Props // Props
const props = defineProps({ const prop = defineProps({
title: { title: {
type: String, type: String,
default: '라벨', default: '라벨',
@ -33,7 +29,7 @@
}, },
name: { name: {
type: String, type: String,
default: 'fileInput', default: 'nameplz',
required: true, required: true,
}, },
isAlert: { isAlert: {
@ -42,28 +38,22 @@
required: false, required: false,
}, },
}); });
//:key="autoIncrement" .
const inputId = computed(() => props.name || 'defaultFileInput');
const emits = defineEmits(['update:data', 'update:isValid']); const emits = defineEmits(['update:data', 'update:isValid']);
const autoIncrement = ref(props.autoIncrement);
const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_FILE_COUNT = 5; // const MAX_FILE_COUNT = 5; //
const ALLOWED_FILE_TYPES = []; // const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'application/pdf']; //
const showError = ref(false); const showError = ref(false);
const fileMsgKey = ref(''); // const fileMsgKey = ref(''); //
const changeHandler = event => { const changeHandler = (event) => {
const files = Array.from(event.target.files); const files = Array.from(event.target.files);
const totalSize = files.reduce((sum, file) => sum + file.size, 0); const totalSize = files.reduce((sum, file) => sum + file.size, 0);
const invalidFiles = files.filter(file => !ALLOWED_FILE_TYPES.includes(file.type));
// ALLOWED_FILE_TYPES //
const invalidFiles = ALLOWED_FILE_TYPES.length > 0 ? files.filter(file => !ALLOWED_FILE_TYPES.includes(file.type)) : [];
if (totalSize > MAX_TOTAL_SIZE) { if (totalSize > MAX_TOTAL_SIZE) {
showError.value = true; showError.value = true;
fileMsgKey.value = 'FileMaxSizeMsg'; fileMsgKey.value = 'FileMaxSizeMsg';
@ -87,5 +77,5 @@
} }
}; };
const errorMessage = computed(() => fileMsg[fileMsgKey.value] || ''); const errorMessage = computed(() => (fileMsg[fileMsgKey.value] || ''));
</script> </script>

View File

@ -5,7 +5,6 @@
<span v-if="isEssential" class="text-danger">*</span> <span v-if="isEssential" class="text-danger">*</span>
</label> </label>
<div class="col-md-10"> <div class="col-md-10">
<div class="d-flex align-items-center">
<input <input
:id="name" :id="name"
class="form-control" class="form-control"
@ -14,18 +13,11 @@
:maxLength="maxlength" :maxLength="maxlength"
:placeholder="title" :placeholder="title"
:disabled="disabled" :disabled="disabled"
:min="min"
autocomplete="off"
@focusout="$emit('focusout', modelValue)"
@input="handleInput"
/> />
<div v-if="isBtn" class="ms-2"> <div class="invalid-feedback" :class="isAlert ? 'display-block' : ''">
<slot name="append"></slot> {{ title }} 확인해주세요.
</div> </div>
</div> </div>
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div>
<div class="invalid-feedback" :class="isCateAlert ? 'd-block' : ''">카테고리 중복입니다.</div>
</div>
</div> </div>
</template> </template>
@ -64,10 +56,6 @@ import { ref, watch } from 'vue';
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isCateAlert: {
type: Boolean,
default: false,
},
isLabel : { isLabel : {
type: Boolean, type: Boolean,
default: true, default: true,
@ -76,45 +64,30 @@ import { ref, watch } from 'vue';
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false, default: false,
},
min: {
type: String,
default: '',
required: false,
},
isBtn: {
type: Boolean,
default: false,
required: false,
} }
}); });
// Emits // Emits
const emits = defineEmits(['update:modelValue', 'focusout', 'update:alert']); const emits = defineEmits(['update:modelValue']);
// `inputValue` // `inputValue`
const inputValue = ref(props.modelValue); const inputValue = ref(props.modelValue);
// //
watch(inputValue, newValue => { watch(inputValue, (newValue) => {
emits('update:modelValue', newValue); emits('update:modelValue', newValue);
}); });
// //
watch( watch(() => props.modelValue, (newValue) => {
() => props.modelValue,
newValue => {
if (inputValue.value !== newValue) { if (inputValue.value !== newValue) {
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);
}
};
</script> </script>
<style>
.none {
display: none;
}
</style>

View File

@ -2,44 +2,21 @@
<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'">
<div class="d-flex gap-2 align-items-center"> <select class="form-select" :id="name" v-model="selectData" :disabled="disabled">
<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">
<option>사용가능한 컬러가 없습니다</option>
</select>
<!-- 데이터가 있는 경우 원래 select 표시 -->
<select v-else class="form-select" :id="name" v-model="selectData" :disabled="disabled" :style="isColor ? { color: selected } : {}" @blur="$emit('blur')">
<option v-for="(item, i) in data" :key="i" :value="isCommon ? item.value : i" :style="isColor ? { color: item.label } : {}">
{{ isCommon ? item.label : item }} {{ isCommon ? item.label : item }}
</option> </option>
</select> </select>
<div v-if="isBtn" class="ms-2">
<slot name="append"></slot>
</div>
<div v-if="isColor && selected"
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 class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div>
</div> </div>
<div v-if="isAlert" class="invalid-feedback">{{ title }} 확인해주세요.</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue'; import { ref, watch } from 'vue';
const props = defineProps({ const props = defineProps({
title: { title: {
@ -82,11 +59,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,
@ -95,20 +67,10 @@ const props = defineProps({
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false default: false
}, }
isColor: {
type: Boolean,
default: false,
required: false,
},
isMbti: {
type: Boolean,
default: false,
required: false,
},
}); });
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 +85,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 });
@ -135,14 +93,4 @@ watch(() => props.data, (newData) => {
watch(selectData, (newValue) => { watch(selectData, (newValue) => {
emit('update:data', newValue); emit('update:data', newValue);
}); });
const selected = computed(() => {
//
const selectedItem = props.data.find(item =>
props.isCommon ? item.value === selectData.value : props.data.indexOf(item) === selectData.value
);
return selectedItem ? selectedItem.label : null;
});
</script> </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,213 +1,64 @@
<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">
<h5 class="card-title fw-bold">
{{ title }} {{ title }}
</h5> </h5>
<div v-if="!isProjectExpired" class="d-flex gap-1">
<EditBtn @click.stop="openEditModal" />
<DeleteBtn v-if="isProjectCreator" @click.stop="handleDelete" class="ms-1"/>
</div>
</div>
<!-- 날짜 --> <!-- 날짜 -->
<div class="row align-items-center pb-2"> <div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<div class="col-3 col-md-2 d-flex align-items-center">
<i class="bx bx-calendar"></i> <i class="bx bx-calendar"></i>
<div class="ms-2">날짜</div> <div class="ms-2">날짜</div>
<div class="ms-12">{{ strdate }} ~ {{ enddate }}</div>
</div> </div>
<div class="col-9 col-md-10">
{{ strdate }} ~ {{ enddate }}
</div>
</div>
<!-- 참여자 --> <!-- 참여자 -->
<div class="row align-items-center pb-2"> <div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<div class="col-3 col-md-2 d-flex align-items-center">
<i class="bx bxs-user"></i> <i class="bx bxs-user"></i>
<div class="ms-2">참여자</div> <div class="ms-2">참여자</div>
<UserList :projctSeq="projctSeq" class="ms-8 mb-0" />
</div> </div>
<div class="col-9 col-md-10">
<UserList ref="userListRef" :projctSeq="projctSeq" :showOnlyActive="true" class="mb-0" />
</div>
</div>
<!-- 설명 --> <!-- 설명 -->
<div class="row align-items-center pb-2"> <div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<div class="col-3 col-md-2 d-flex align-items-center">
<i class="bx bx-detail"></i> <i class="bx bx-detail"></i>
<div class="ms-2">설명</div> <div class="ms-2">설명</div>
<div class="ms-12">{{ description }}</div>
</div> </div>
<div class="col-9 col-md-10">
{{ description || '-' }}
</div>
</div>
<!-- 주소 --> <!-- 주소 -->
<div class="row align-items-center pb-2"> <div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<div class="col-3 col-md-2 d-flex align-items-center">
<MapPopover
:address="address"
:is-visible="isMapVisible"
@update-visible="updatePopover"
>
<template #trigger>
<div class="d-flex align-items-center cursor-pointer">
<i class="bx bxs-map"></i> <i class="bx bxs-map"></i>
<div class="ms-2">주소</div> <div class="ms-2">주소</div>
</div> <div class="ms-12">{{ address }}</div>
</template> <button type="button" class="btn ms-auto text-white" :style="`background-color: ${projctCol} !important;`" @click.stop="openModal">log</button>
</MapPopover>
</div>
<div class="col-9 col-md-10 d-flex justify-content-between align-items-center">
<div>{{ address }} {{ addressdtail }}</div>
<button type="button" class="btn text-white" :style="`background-color: ${projctColor} !important;`" @click.stop="openModal">
<i class='bx bx-child'></i>
</button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- 로그 모달 -->
<CenterModal :display="isModalOpen" @close="closeModal" > <CenterModal :display="isModalOpen" @close="closeModal" >
<template #title> 등록·수정자 </template> <template #title> Log </template>
<template #body> <template #body>
<div v-if="logData.length > 0"> <div class="border border-3 rounded p-5 ms-4 mt-2" v-if="logData">
<div <p class="mb-1">{{ logData.createDate }}</p>
v-for="(log, index) in logData" <strong class="">[{{ logData.creator }}] 프로젝트 등록</strong>
:key="index"
class="ms-4 mt-2 border p-3"
>
<p class="mb-1">{{ log.logDate }}</p>
<strong>{{ log.logMessage }}</strong>
</div> </div>
<div class="border border-3 rounded p-5 ms-4 mt-2" v-if="logData?.updateDate">
<p class="mb-1">{{ logData.updateDate }}</p>
<strong>[{{ logData.updater }}] 프로젝트 수정</strong>
</div> </div>
</template> </template>
<template #footer> <template #footer>
<BackBtn @click="closeModal" /> <button type="button" class="btn btn-secondary" @click="closeModal">닫기</button>
</template>
</CenterModal>
<!-- 수정 모달 -->
<CenterModal :display="isEditModalOpen" @close="closeEditModal">
<template #title> 프로젝트 수정 </template>
<template #body>
<FormInput
title="프로젝트명"
name="name"
:is-essential="true"
:is-alert="nameAlert"
:modelValue="selectedProject.PROJCTNAM"
@update:modelValue="selectedProject.PROJCTNAM = $event"
@update:alert="nameAlert = $event"
/>
<FormSelect
title="컬러"
name="color"
:is-essential="true"
:is-label="true"
:is-common="true"
:is-color="true"
:data="allColors"
:value="selectedProject.PROJCTCOL"
@update:data="selectedProject.PROJCTCOL = $event"
/>
<div class="mb-2 row">
<label class="col-md-2 col-form-label">
참여자
</label>
<div class="col-md-10">
<UserList class="m-0"
ref="editUserListRef"
:projctSeq="projctSeq"
:showOnlyActive="false"
@user-list-update="handleEditUserListUpdate"
/>
</div>
</div>
<!-- 시작일 -->
<div @click="openStartDatePicker">
<FormInput
title="시작일"
type="date"
name="startDay"
:is-essential="true"
:is-alert="startDayAlert"
:modelValue="selectedProject.PROJCTSTR"
@update:modelValue="selectedProject.PROJCTSTR = $event"
ref="startDateInput"
/>
</div>
<!-- 종료일 -->
<div @click="openEndDatePicker">
<FormInput
title="종료일"
type="date"
name="endDay"
:min="selectedProject.PROJCTSTR"
:modelValue="selectedProject.PROJCTEND"
@update:modelValue="selectedProject.PROJCTEND = $event"
ref="endDateInput"
/>
</div>
<FormInput
title="설명"
name="description"
:modelValue="selectedProject.PROJCTDES"
@update:modelValue="selectedProject.PROJCTDES = $event"
/>
<ArrInput
title="주소"
name="address"
:is-essential="true"
:is-row="true"
:modelValue="{
address: selectedProject.PROJCTARR,
detailAddress: selectedProject.PROJCTDTL,
postcode: selectedProject.PROJCTZIP
}"
@update:data="updateAddress"
/>
</template>
<template #footer>
<BackButton @click="closeEditModal" />
<SaveButton @click="handleUpdate" />
</template> </template>
</CenterModal> </CenterModal>
</template> </template>
<script setup> <script setup>
import { defineProps, onMounted, ref, computed, watch } from 'vue'; import { defineProps, ref } 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 BackBtn from '@c/button/BackBtn.vue';
import BackButton from '@c/button/BackBtn.vue';
import SaveButton from '@c/button/SaveBtn.vue';
import EditBtn from '../button/EditBtn.vue';
import DeleteBtn from '../button/DeleteBtn.vue';
import FormInput from '@c/input/FormInput.vue';
import FormSelect from '@c/input/FormSelect.vue';
import ArrInput from '@c/input/ArrInput.vue';
import MapPopover from '@c/map/MapPopover.vue';
import { useToastStore } from '@s/toastStore';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import commonApi, { refreshColorList } from '@/common/commonApi';
import { useProjectStore } from '@/stores/useProjectStore';
//
const toastStore = useToastStore();
const userStore = useUserInfoStore();
const projectStore = useProjectStore();
// Props // Props
const props = defineProps({ const props = defineProps({
@ -227,353 +78,43 @@ const props = defineProps({
description: { description: {
type: String, type: String,
required: false, required: false,
default: "",
}, },
address: { address: {
type: String, type: String,
required: true, required: true,
}, },
addressdtail: {
type: String,
required: true,
},
addressZip: {
type: String,
required: true,
},
projctSeq: { projctSeq: {
type: Number, type: Number,
required: false required: false
}, },
projctCol: { projctCol: {
type: Number,
required: false
},
projctColor: {
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 defineEmits(['click']);
const emit = defineEmits(['update']);
//
const isModalOpen = ref(false); const isModalOpen = ref(false);
const logData = ref([]); const logData = ref(null);
const isMapVisible = ref(null); const fetchLogData = async () => {
try {
// const response = await $api.get(`project/log/${props.projctSeq}`);
const isEditModalOpen = ref(false); logData.value = response.data.data.length > 0 ? response.data.data[0] : {};
const originalColor = ref(''); } catch (error) {
const nameAlert = ref(false); console.error('로그 정보 조회 실패:', error);
const startDayAlert = ref(false);
const user = ref(null);
const editUserListRef = ref(null);
const userListRef = ref(null);
const selectedUsers = ref({
activeUsers: [],
disabledUsers: []
});
const startDateInput = ref(null);
const endDateInput = ref(null);
// DOM
let startInputElement = null;
let endInputElement = null;
const openStartDatePicker = () => {
if (startInputElement) {
startInputElement.showPicker();
} }
}; };
const openEndDatePicker = () => {
if (endInputElement) {
endInputElement.showPicker();
}
};
const updatePopover = (visible) => {
isMapVisible.value = visible;
};
//
const handleEditUserListUpdate = (userLists) => {
selectedUsers.value = userLists;
};
const isProjectCreator = computed(() => {
return user.value?.id === props.projctCreatorId;
});
// ( )
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({
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,
});
//
const { colorList } = commonApi({
loadColor: true,
colorType: 'YNP',
});
// +
const allColors = computed(() => {
// ( )
const existingColor = {
value: props.projctCol, //
label: props.projctColor //
};
//
const otherColors = colorList.value.filter(color => color.value !== existingColor.value);
//
return [existingColor, ...otherColors];
});
// ::
const updateAddress = addressData => {
selectedProject.value = {
...selectedProject.value,
PROJCTZIP: addressData.postcode,
PROJCTARR: addressData.address,
PROJCTDTL: addressData.detailAddress,
};
};
//
const getLogData = async () => {
const res = await $api.get(`project/log/${props.projctSeq}`);
logData.value = res.data.data;
};
//
const openModal = async () => { const openModal = async () => {
await getLogData(); await fetchLogData();
isModalOpen.value = true; isModalOpen.value = true;
}; };
//
const closeModal = () => { const closeModal = () => {
isModalOpen.value = false; isModalOpen.value = false;
}; };
//
const openEditModal = async () => {
selectedProject.value = {
PROJCTSEQ: props.projctSeq,
PROJCTNAM: props.title,
PROJCTSTR: props.strdate,
PROJCTEND: props.enddate,
PROJCTZIP: props.addressZip,
PROJCTARR: props.address,
PROJCTDTL: props.addressdtail,
PROJCTDES: props.description,
PROJCTCOL: props.projctCol,
projctcolor: props.projctColor,
};
isEditModalOpen.value = true;
originalColor.value = props.projctCol;
};
//
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;
// UserList resetSelection
if (editUserListRef.value) {
editUserListRef.value.resetSelection();
}
};
// selectedUsers
watch(() => selectedUsers.value.activeUsers, (newVal, oldVal) => {
}, { deep: true });
watch(() => selectedUsers.value.disabledUsers, (newVal, oldVal) => {
}, { deep: true });
//
const hasChanges = computed(() => {
//
const basicChanges = selectedProject.value.PROJCTNAM !== props.title ||
selectedProject.value.PROJCTSTR !== props.strdate ||
selectedProject.value.PROJCTEND !== props.enddate ||
selectedProject.value.PROJCTZIP !== props.addressZip ||
selectedProject.value.PROJCTARR !== props.address ||
selectedProject.value.PROJCTDTL !== props.addressdtail ||
selectedProject.value.PROJCTDES !== props.description ||
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 () => {
nameAlert.value = selectedProject.value.PROJCTNAM.trim() === '';
startDayAlert.value = selectedProject.value.PROJCTSTR.trim() === '';
if (nameAlert.value || startDayAlert.value) {
return;
}
if (!hasChanges.value) {
toastStore.onToast('변경된 내용이 없습니다.', 'e');
return;
}
const disabledMemberSeqs = selectedUsers.value.disabledUsers.map(user => user.MEMBERSEQ);
const res = await $api.patch('project/update', {
projctSeq: selectedProject.value.PROJCTSEQ,
projctNam: selectedProject.value.PROJCTNAM,
projctCol: selectedProject.value.PROJCTCOL,
projctArr: selectedProject.value.PROJCTARR,
projctDtl: selectedProject.value.PROJCTDTL,
projctZip: selectedProject.value.PROJCTZIP,
projctStr: selectedProject.value.PROJCTSTR,
projctEnd: selectedProject.value.PROJCTEND || null,
projctDes: selectedProject.value.PROJCTDES || null,
projctUmb: user.value?.id,
originalColor: originalColor.value === selectedProject.value.PROJCTCOL ? null : originalColor.value,
disabledMembers: disabledMemberSeqs
});
if (res.status === 200) {
toastStore.onToast('수정이 완료 되었습니다.', 's');
//
await projectStore.getProjectList(props.searchParams.text, props.searchParams.year, 'false');
await projectStore.getMemberProjects();
await refreshColorList('YNP');
await editUserListRef.value.fetchProjectParticipation();
await userListRef.value.fetchProjectParticipation();
closeEditModal();
emit('update', props.searchParams);
}
};
//
const handleDelete = () => {
if (confirm('프로젝트를 삭제하시겠습니까?')) {
$api.patch('project/delete', {
projctSeq: props.projctSeq,
projctCol: props.projctCol,
})
.then(res => {
if (res.status === 200) {
toastStore.onToast('프로젝트가 삭제되었습니다.', 's');
projectStore.getProjectList();
projectStore.getMemberProjects();
}
})
}
};
//
onMounted(async () => {
//
await userStore.userInfo();
user.value = userStore.user;
if (startDateInput.value) {
// FormInput input
startInputElement = startDateInput.value.$el.querySelector('input[type="date"]');
}
if (endDateInput.value) {
endInputElement = endDateInput.value.$el.querySelector('input[type="date"]');
}
});
</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,12 +1,13 @@
<template> <template>
<div @click="closeModal" class="modal fade scrollbar-none" :class="{ 'show': display , 'd-block': display , 'bg-dark bg-opacity-50' : display }" id="modalCenter" tabindex="-1" aria-modal="true" role="dialog"> <div @click="closeModal" class="modal fade scrollbar-none" :class="{ 'show': display, 'display-block': display , 'modal-back' : display }" id="modalCenter" tabindex="-1" aria-modal="true" role="dialog">
<div @click.stop class="modal-dialog modal-dialog-centered" role="document"> <div @click.stop class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title m-auto fw-bold" id="modalCenterTitle"> <h5 class="modal-title m-auto fw-bold" id="modalCenterTitle">
<slot name="title">Modal Title</slot> <slot name="title">Modal Title</slot>
</h5> </h5>
<button type="button" class="btn-close" @click="closeModal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<slot name="body">Modal body</slot> <slot name="body">Modal body</slot>
@ -20,31 +21,26 @@
</template> </template>
<script setup> <script setup>
const prop = defineProps({ const prop = defineProps({
display : { display : {
type: Boolean, type: Boolean,
default: false, default: false,
required: true, required: true,
}, },
create: {
type: Boolean,
default: false,
}
}); });
const emit = defineEmits(['close' , 'reset']); const emit = defineEmits(['close']);
const closeModal = () => { const closeModal = () => {
if (prop.create) {
emit('reset');
}
emit('close' , false); emit('close' , false);
}; };
</script> </script>
<style>
.modal-back {
background: rgba(0, 0, 0, 0.5);
}
</style>

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,38 +1,33 @@
<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">
<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"> <div class="modal-body">
<p class="vac-modal-text">선물할 연차 개수를 선택해 주세요.</p> <p>해당 직원에게 부여할 연차 개수를 선택하세요. (남은 개수: {{ availableQuota }})</p>
<div class="count-container">
<div class="vacation-control">
<button @click="decreaseCount" :disabled="grantCount < 2" class="count-btn">-</button> <button @click="decreaseCount" :disabled="grantCount < 2" class="count-btn">-</button>
<span class="count-value">{{ grantCount }}</span> <span class="grant-count">{{ grantCount }}</span>
<button @click="increaseCount" :disabled="grantCount >= availableQuota" class="count-btn">+</button> <button @click="increaseCount" :disabled="grantCount >= availableQuota" class="count-btn">+</button>
</div> </div>
<div class="custom-button-container">
<button class="custom-button" @click="saveVacationGrant" :disabled="grantCount === 0 || isGiftButtonDisabled"> <button class="gift-btn" @click="saveVacationGrant" :disabled="grantCount === 0">
<i class="bx bx-gift"></i> <i class="bx bx-gift"></i>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup> <script setup>
import { ref, defineProps, defineEmits, watch, onMounted, computed } from "vue"; import { ref, defineProps, defineEmits, watch, onMounted } from "vue";
import axios from "@api"; import axios from "@api";
import { useToastStore } from '@s/toastStore';
import { useUserInfoStore } from "@s/useUserInfoStore";
const userStore = useUserInfoStore();
const toastStore = useToastStore();
const props = defineProps({ const props = defineProps({
isOpen: Boolean, isOpen: Boolean,
targetUser: Object, targetUser: Object,
remainingVacationData: Object,
}); });
const emit = defineEmits(["close", "updateVacation"]); const emit = defineEmits(["close", "updateVacation"]);
@ -41,85 +36,169 @@ const maxQuota = 2;
const sentCount = ref(0); const sentCount = ref(0);
const availableQuota = ref(2); const availableQuota = ref(2);
const myUserId = computed(() => userStore.user.id);
const myRemainingQuota = computed(() => {
return props.remainingVacationData?.[myUserId.value] ?? 0;
});
const isGiftButtonDisabled = computed(() => {
return myRemainingQuota.value < 0;
});
//
const fetchSentVacationCount = async () => { const fetchSentVacationCount = async () => {
try { try {
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) {
console.error("🚨 연차 전송 기록 조회 실패:", error);
availableQuota.value = maxQuota; availableQuota.value = maxQuota;
grantCount.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], date: new Date().toISOString().split("T")[0],
type: "700103", type: "700103",
receiverId: props.targetUser.MEMBERSEQ, receiverId: props.targetUser.MEMBERSEQ,
count: grantCount.value, count: grantCount.value,
}]; },
];
console.log(props.targetUser)
console.log(payload)
const response = await axios.post("vacation", payload); const response = await axios.post("vacation", payload);
if (response.data?.status === "OK") { console.log(response)
toastStore.onToast('연차가 선물되었습니다.', 's'); if (response.data && response.data.status === "OK") {
alert("✅ 연차가 부여되었습니다.");
await fetchSentVacationCount(); await fetchSentVacationCount();
emit("updateVacation"); emit("updateVacation");
closeModal(); closeModal();
} else { } else {
toastStore.onToast(' 연차 선물 중 오류가 발생했습니다.', 'e'); alert("🚨 연차 추가 중 오류가 발생했습니다.");
} }
} catch (error) { } catch (error) {
toastStore.onToast(' 연차 선물 실패!!.', 'e'); console.error("🚨 연차 추가 실패:", error);
alert("연차 추가에 실패했습니다.");
} }
}; };
//
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) {
console.log("🟢 모달이 열렸습니다. 데이터를 로드합니다.");
await fetchSentVacationCount(); await fetchSentVacationCount();
} }
}); }
watch(() => props.targetUser, async (newUser) => { );
if (newUser?.MEMBERSEQ) {
watch(
() => props.targetUser,
async (newUser) => {
if (newUser && newUser.MEMBERSEQ) {
await fetchSentVacationCount(); await fetchSentVacationCount();
} }
}, { deep: true }); },
{ deep: true }
);
onMounted(async () => { onMounted(async () => {
if (props.isOpen && props.targetUser?.MEMBERSEQ) { if (props.isOpen && props.targetUser && props.targetUser.MEMBERSEQ) {
await fetchSentVacationCount(); await fetchSentVacationCount();
} }
}); });
</script> </script>
<style scoped> <style scoped>
/* 모달 본문 */
.modal-content {
background: white;
padding: 20px;
border-radius: 12px;
width: 400px;
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.15);
text-align: center;
position: relative;
}
/* 닫기 버튼 */
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
}
/* 연차 개수 조정 버튼 */
.vacation-control {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
margin-top: 15px;
}
.count-btn {
font-size: 18px;
padding: 6px 12px;
border: none;
background: #007bff;
color: white;
border-radius: 5px;
cursor: pointer;
}
.count-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
/* 개수 표시 */
.grant-count {
font-size: 20px;
font-weight: bold;
color: #333;
}
/* 선물 아이콘 버튼 */
.gift-btn {
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: #28a745;
color: white;
border: none;
border-radius: 8px;
padding: 10px 15px;
margin-top: 15px;
cursor: pointer;
transition: 0.3s;
}
.gift-btn:hover {
background: #218838;
}
.gift-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
</style> </style>

View File

@ -1,33 +1,39 @@
<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 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"> <div class="modal-body" v-if="mergedVacations.length > 0">
<ol class="list-group-numbered px-0 mt-4"> <ol class="vacation-list">
<li <li
v-for="(vac, index) in mergedVacations" v-for="(vac, index) in mergedVacations"
:key="vac._expandIndex" :key="vac._expandIndex"
class="vacation-item" class="vacation-item"
> >
<span :class="vac.category === 'used' ? 'fw-bold text-danger me-2' : 'fw-bold text-primary me-2'"> <!-- Used 항목만 인덱스 표시 -->
<span v-if="vac.category === 'used'" class="vacation-index">
{{ usedVacationIndexMap[vac._expandIndex] }})
</span>
<span :class="vac.category === 'used' ? 'minus-symbol' : 'plus-symbol'">
{{ vac.category === 'used' ? '-' : '+' }} {{ vac.category === 'used' ? '-' : '+' }}
</span> </span>
<span <span
:style="{ color: userColors[vac.senderId || vac.receiverId] || '#000' }" :style="{ color: userColors[vac.senderId || vac.receiverId] || '#000' }"
class="vacation-date"
> >
{{ formatDate(vac.date) }} {{ formatDate(vac.date) }}
</span> </span>
<span v-if="vac.category === 'used'" class="fw-bold text-dark ms-1">
( {{ usedVacationIndexMap[vac._expandIndex] }} )
</span>
</li> </li>
</ol> </ol>
</div> </div>
<!-- 연차 데이터 없음 --> <!-- 연차 데이터 없음 -->
<p v-else class="text-sm-center mt-10 text-gray vac-modal-title"> <p v-else class="no-data">
🚫 연차 내역이 없습니다. 🚫 사용한 연차가 없습니다.
</p> </p>
</div> </div>
</div> </div>
@ -35,7 +41,6 @@
<script setup> <script setup>
import { defineProps, defineEmits, computed } from "vue"; import { defineProps, defineEmits, computed } from "vue";
import { formatDate } from '@/common/formattedDate.js';
const props = defineProps({ const props = defineProps({
isOpen: Boolean, isOpen: Boolean,
@ -55,44 +60,50 @@ const props = defineProps({
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++) {
result.push({
...v, ...v,
category: "used", category: "used",
code: v.LOCVACTYP, code: v.LOCVACTYP, // (700103 )
used_quota: count, //
_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 = {};
@ -104,7 +115,10 @@ const usedVacationIndexMap = computed(() => {
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];
// + // +
@ -116,11 +130,96 @@ const mergedVacations = computed(() => {
return all; return all;
}); });
// /** 날짜 포맷 (YYYY-MM-DD) */
const formatDate = (dateString) => {
if (!dateString) return "";
// dateString "YYYY-MM-DD"
// "YYYY-MM-DD..." 10
return dateString.substring(0, 10);
};
/** 모달 닫기 */
const closeModal = () => { const closeModal = () => {
emit("close"); emit("close");
}; };
</script> </script>
<style scoped>
</style>
<style scoped>
/* 모달 본문 */
.modal-content {
background: white;
padding: 20px;
border-radius: 12px;
width: 400px;
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.15);
text-align: center;
position: relative;
}
/* 닫기 버튼 */
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
}
/* 리스트 기본 스타일 */
.vacation-list {
list-style-type: none;
padding-left: 0;
margin-top: 15px;
}
/* 리스트 아이템 */
.vacation-item {
display: flex;
align-items: center;
font-size: 16px;
font-weight: bold;
margin-bottom: 8px;
padding: 5px 10px;
border-radius: 5px;
background: #f9f9f9;
}
/* 인덱스 (연차 사용 개수) */
.vacation-index {
font-weight: bold;
font-size: 16px;
margin-right: 8px;
color: #333;
}
/* "-" 빨간색 */
.minus-symbol {
color: red;
font-weight: bold;
margin-right: 8px;
}
/* "+" 파란색 */
.plus-symbol {
color: blue;
font-weight: bold;
margin-right: 8px;
}
/* 날짜 스타일 */
.vacation-date {
font-size: 16px;
color: #333;
}
/* 연차 데이터 없음 */
.no-data {
text-align: center;
font-size: 14px;
color: gray;
margin-top: 10px;
}
</style>

View File

@ -7,42 +7,32 @@
<!-- 프로젝트 목록 --> <!-- 프로젝트 목록 -->
<div class="mt-4"> <div class="mt-4">
<div v-if="projectStore.projectList.length === 0" class="text-center"> <div v-if="projectList.length === 0" class="text-center">
<p class="text-muted mt-4">등록된 프로젝트가 없습니다.</p> <p class="text-muted mt-4">게시물이 없습니다.</p>
</div> </div>
<div v-for="post in projectStore.projectList" :key="post.PROJCTSEQ"> <div v-for="post in projectList" :key="post.PROJCTSEQ" @click="openEditModal(post)" class="cursor-pointer">
<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 + ' ' + post.PROJCTDTL"
:addressdtail="post.PROJCTDTL"
:addressZip="post.PROJCTZIP"
:projctSeq="post.PROJCTSEQ" :projctSeq="post.PROJCTSEQ"
:projctCol="post.PROJCTCOL" :projctCol="post.projctcolor"
:projctColor="post.projctcolor"
:projctCreatorId="post.PROJCTCMB"
:resetUserSelection="resetUserSelection"
:searchParams="{ text: searchText, year: selectedYear }"
@update="handleProjectUpdate"
/> />
</div> </div>
</div> </div>
<!-- 등록 모달 --> <!-- 등록 모달 -->
<form @reset.prevent="formReset"> <CenterModal :display="isCreateModalOpen" @close="closeCreateModal">
<CenterModal :display="isCreateModalOpen" @close="closeCreateModal" :create="true" @reset="formReset">
<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"
:modelValue="name"
@update:alert="nameAlert = $event"
@update:modelValue="name = $event" @update:modelValue="name = $event"
/> />
@ -52,53 +42,28 @@
:is-essential="true" :is-essential="true"
:is-label="true" :is-label="true"
:is-common="true" :is-common="true"
:is-color="true"
:value="color"
:data="colorList" :data="colorList"
@update:data="color = $event" @update:data="color = $event"
:is-alert="colorAlert"
/> />
<div class="mb-2 row">
<label class="col-md-2 col-form-label">
참여자
</label>
<div class="col-md-10">
<UserList
ref="userListRef"
@user-list-update="handleUserListUpdate"
class="m-0"
/>
</div>
</div>
<div @click="openStartDatePicker">
<FormInput <FormInput
title="시작 일" title="시작 일"
type="date"
name="startDay" name="startDay"
:type="'date'"
:is-alert="startDayAlert"
:is-essential="true"
:modelValue="startDay"
v-model="startDay" v-model="startDay"
ref="startDateInput" :is-essential="true"
/> />
</div>
<div @click="openEndDatePicker">
<FormInput <FormInput
title="종료 일" title="종료 일"
name="endDay" name="endDay"
:type="'date'" :type="'date'"
:modelValue="endDay"
:min="startDay"
@update:modelValue="endDay = $event" @update:modelValue="endDay = $event"
ref="endDateInput"
/> />
</div>
<FormInput <FormInput
title="설명" title="설명"
name="description" name="description"
:modelValue="description"
@update:modelValue="description = $event" @update:modelValue="description = $event"
/> />
@ -108,108 +73,138 @@
:isEssential="true" :isEssential="true"
:is-row="true" :is-row="true"
:is-alert="addressAlert" :is-alert="addressAlert"
:modelValue="addressData"
@update:data="handleAddressUpdate" @update:data="handleAddressUpdate"
@update:alert="addressAlert = $event" @update:alert="addressAlert = $event"
/> />
</template> </template>
<template #footer> <template #footer>
<BackButton type="reset" @click="closeCreateModal" /> <BackButton @click="closeCreateModal" />
<SaveButton @click="handleCreate" /> <SaveButton @click="handleCreate" />
</template> </template>
</CenterModal> </CenterModal>
</form>
<!-- 수정 모달 -->
<CenterModal :display="isEditModalOpen" @close="closeEditModal">
<template #title> 프로젝트 수정 </template>
<template #body>
<FormInput
title="이름"
name="name"
:is-essential="true"
:is-alert="nameAlert"
:modelValue="selectedProject.PROJCTNAM"
@update:modelValue="selectedProject.PROJCTNAM = $event"
/>
<FormSelect
title="컬러"
name="color"
:is-essential="true"
:is-label="true"
:is-common="true"
:data="allColors"
:value="selectedProject.PROJCTCOL"
@update:data="selectedProject.PROJCTCOL = $event"
/>
<FormInput
title="시작일"
type="date"
name="startDay"
:is-essential="true"
:modelValue="selectedProject.PROJCTSTR"
@update:modelValue="selectedProject.PROJCTSTR = $event"
/>
<FormInput
title="종료일"
type="date"
name="endDay"
:modelValue="selectedProject.PROJCTEND"
@update:modelValue="selectedProject.PROJCTEND = $event"
/>
<FormInput
title="설명"
name="description"
:modelValue="selectedProject.PROJCTDES"
@update:modelValue="selectedProject.PROJCTDES = $event"
/>
<ArrInput
title="주소"
name="address"
:is-essential="true"
:is-row="true"
:modelValue="selectedProject"
@update:data="updateAddress"
/>
</template>
<template #footer>
<BackButton @click="closeEditModal" />
<SaveButton @click="handleUpdate" />
</template>
</CenterModal>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch, onMounted, inject, nextTick } from 'vue'; import { computed, inject, ref, watch, onMounted } 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 '@c/category/CategoryBtn.vue';
import WriteBtn from '@c/button/WriteBtn.vue'; import WriteBtn from '@c/button/WriteBtn.vue';
import CenterModal from '@c/modal/CenterModal.vue'; import CenterModal from '@c/modal/CenterModal.vue';
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 $api from '@api'; import $api from '@api';
import SaveButton from '@c/button/SaveBtn.vue'; import SaveButton from '@c/button/SaveBtn.vue';
import BackButton from '@c/button/BackBtn.vue'; import BackButton from '@c/button/BackBtn.vue'
const dayjs = inject('dayjs');
const today = dayjs().format('YYYY-MM-DD');
const toastStore = useToastStore(); const toastStore = useToastStore();
const userStore = useUserInfoStore(); const userStore = useUserInfoStore();
const projectStore = useProjectStore();
// //
const user = ref(null); const user = ref(null);
const projectList = ref([]);
const filteredProjects = ref([]);
const selectedCategory = ref(null); const selectedCategory = ref(null);
const searchText = ref(''); const searchText = ref('');
const userListRef = ref(null);
const resetUserSelection = ref(false);
// dayjs
const dayjs = inject('dayjs');
// YYYY-MM-DD
const today = dayjs().format('YYYY-MM-DD');
// //
const isCreateModalOpen = ref(false); const isCreateModalOpen = ref(false);
const name = ref(''); const name = ref('');
const color = ref('0'); const color = ref('');
const address = ref('');
const detailAddress = ref('');
const postcode = 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 isEditModalOpen = ref(false);
const endDateInput = ref(null); const originalColor = ref('');
const selectedProject = ref({
// DOM PROJCTSEQ: '',
let startInputElement = null; PROJCTNAM: '',
let endInputElement = null; PROJCTSTR: '',
PROJCTEND: '',
PROJCTZIP: '',
const openStartDatePicker = () => { PROJCTARR: '',
if (startInputElement) { PROJCTDTL: '',
startInputElement.showPicker(); PROJCTDES: '',
} PROJCTCOL: '',
}; projctcolor: '',
const openEndDatePicker = () => {
if (endInputElement) {
endInputElement.showPicker();
}
};
const addressData = ref({
postcode: '',
address: '',
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,
@ -217,8 +212,19 @@
loadYearCategory: true, loadYearCategory: true,
}); });
//
const getProjectList = async () => {
const res = await $api.get('project/select', {
params: {
searchKeyword : searchText.value,
category : selectedYear.value,
},
});
projectList.value = res.data.data.projectList;
};
// //
const search = async searchKeyword => { const search = async (searchKeyword) => {
searchText.value = searchKeyword.trim(); searchText.value = searchKeyword.trim();
await getProjectList(); await getProjectList();
}; };
@ -231,10 +237,6 @@
return yearCategory.value.find(item => item.value === selectedCategory.value)?.label || null; return yearCategory.value.find(item => item.value === selectedCategory.value)?.label || null;
}); });
//
const getProjectList = async () => {
await projectStore.getProjectList(searchText.value, selectedYear.value, 'false');
};
// //
watch(selectedCategory, async () => { watch(selectedCategory, async () => {
@ -242,137 +244,123 @@
}); });
// //
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 = () => {
name.value = '';
if (colorList.value && colorList.value.length > 0) {
color.value = colorList.value[0].value;
}
addressData.value = {
postcode: '',
address: '',
detailAddress: ''
};
startDay.value = today;
endDay.value = '';
description.value = '';
nameAlert.value = false;
addressAlert.value = false;
startDayAlert.value = false;
selectedUsers.value = {
activeUsers: [],
disabledUsers: []
};
if (userListRef.value) {
userListRef.value.resetSelection();
}
};
// :: // ::
const handleAddressUpdate = (data) => { const handleAddressUpdate = addressData => {
addressData.value = data; address.value = addressData.address;
detailAddress.value = addressData.detailAddress;
postcode.value = addressData.postcode;
}; };
//
watch(startDay, (newStartDate) => {
if (new Date(newStartDate) > new Date(endDay.value)) {
endDay.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 = address.value.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,
projctEnd: endDay.value || null, projctEnd: endDay.value || null,
projctDes: description.value || null, projctDes: description.value || null,
projctArr: addressData.value.address, projctArr: address.value,
projctDtl: addressData.value.detailAddress, projctDtl: detailAddress.value,
projctZip: addressData.value.postcode, projctZip: postcode.value,
projctCmb: user.value.id, projctCmb: user.value.name,
disabledMembers: disabledMemberSeqs })
.then(res => {
if (res.status === 200) {
toastStore.onToast('프로젝트가 등록되었습니다.', 's');
closeCreateModal();
location.reload();
}
});
};
//
const openEditModal = (post) => {
isEditModalOpen.value = true;
selectedProject.value = { ...post };
originalColor.value = post.PROJCTCOL;
};
const closeEditModal = () => {
isEditModalOpen.value = false;
};
// +
const allColors = computed(() => {
const existingColor = { value: selectedProject.value.PROJCTCOL, label: selectedProject.value.projctcolor };
return [existingColor, ...colorList.value];
}); });
if (response.status === 200) { //
toastStore.onToast('프로젝트가 등록되었습니다.', 's'); const hasChanges = computed(() => {
const original = projectList.value.find(p => p.PROJCTSEQ === selectedProject.value.PROJCTSEQ);
if (!original) return false;
colorList.value = colorList.value.filter(c => c.value !== color.value); return (
original.PROJCTNAM !== selectedProject.value.PROJCTNAM ||
original.PROJCTCOL !== selectedProject.value.PROJCTCOL ||
original.PROJCTARR !== selectedProject.value.PROJCTARR ||
original.PROJCTDTL !== selectedProject.value.PROJCTDTL ||
original.PROJCTZIP !== selectedProject.value.PROJCTZIP ||
original.PROJCTSTR !== selectedProject.value.PROJCTSTR ||
original.PROJCTEND !== selectedProject.value.PROJCTEND ||
original.PROJCTDES !== selectedProject.value.PROJCTDES
);
});
formReset(); // ::
const updateAddress = (addressData) => {
selectedProject.value = {
...selectedProject.value,
PROJCTZIP: addressData.postcode,
PROJCTARR: addressData.address,
PROJCTDTL: addressData.detailAddress
};
};
await getProjectList(); const handleUpdate = () => {
await projectStore.getMemberProjects(); if (!hasChanges.value) {
toastStore.onToast('변경된 내용이 없습니다.', 'e');
closeCreateModal(); return;
resetUserSelection.value = !resetUserSelection.value;
} }
$api.patch('project/update', {
projctSeq: selectedProject.value.PROJCTSEQ,
projctNam: selectedProject.value.PROJCTNAM,
projctCol: selectedProject.value.PROJCTCOL,
projctArr: selectedProject.value.PROJCTARR,
projctDtl: selectedProject.value.PROJCTDTL,
projctZip: selectedProject.value.PROJCTZIP,
projctStr: selectedProject.value.PROJCTSTR,
projctEnd: selectedProject.value.PROJCTEND || null,
projctDes: selectedProject.value.PROJCTDES,
projctUmb: user.value.name,
originalColor: originalColor.value === selectedProject.value.PROJCTCOL ? null : originalColor.value
}).then(res => {
if (res.status === 200) {
toastStore.onToast('수정이 완료 되었습니다.', 's');
closeEditModal();
location.reload();
}
});
}; };
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,25 @@
<template> <template>
<form @submit.prevent="search">
<div class="input-group mb-3 d-flex"> <div class="input-group mb-3 d-flex">
<input type="text" class="form-control" placeholder="Search" v-model="searchQuery" @input="preventLeadingSpace" /> <input type="text" class="form-control" placeholder="Search" @change="search" />
<button type="submit" class="btn btn-primary"> <button type="button" class="btn btn-primary"><i class="bx bx-search bx-md"></i></button>
<i class="bx bx-search bx-md"></i>
</button>
</div> </div>
</form>
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue';
const props = defineProps({ const props = defineProps({
maxlength: { maxlength: {
type: Number, type: Number,
default: 30, default: 30,
required: false, required: false,
}, },
initKeyword: {
type: String,
},
}); });
const emits = defineEmits(['update:data']); const emits = defineEmits(['update:data']);
const searchQuery = ref(''); const search = function (event) {
//Type Number maxlength
watch( if (event.target.value.length > props.maxlength) {
() => props.initKeyword, event.target.value = event.target.value.slice(0, props.maxlength);
(newVal, oldVal) => {
searchQuery.value = newVal;
},
);
// ( or )
const search = () => {
const trimmedQuery = searchQuery.value.trimStart();
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 = () => {
searchQuery.value = searchQuery.value.trimStart();
}; };
</script> </script>

View File

@ -40,11 +40,10 @@
@update:alert="pwhintResAlert = $event" @update:alert="pwhintResAlert = $event"
:value="pwhintRes" :value="pwhintRes"
/> />
<div class="d-flex gap-2 mt-7 mb-3"> <div class="d-flex mt-5">
<BackBtn class=" w-50" @click="handleback"/> <RouterLink type="button" class="btn btn-secondary me-2 w-50" to="/login">취소</RouterLink>
<SaveBtn class="w-50" @click="handleSubmit" /> <button type="button" @click="handleSubmit" class="btn btn-primary w-50">확인</button>
</div> </div>
<p v-if="userCheckMsg" class="invalid-feedback d-block mb-0">{{ userCheckMsg }}</p>
</template> </template>
</div> </div>
@ -68,29 +67,25 @@
:is-alert="passwordcheckAlert" :is-alert="passwordcheckAlert"
@update:data="passwordcheck = $event" @update:data="passwordcheck = $event"
@update:alert="passwordcheckAlert = $event" @update:alert="passwordcheckAlert = $event"
@input="checkPw" @blur="checkPw"
:value="passwordcheck" :value="passwordcheck"
/> />
<span v-if="passwordcheckError" class="invalid-feedback d-block">{{ passwordcheckError }}</span> <span v-if="passwordcheckError" class="invalid-feedback d-block">{{ passwordcheckError }}</span>
<div class="d-flex gap-2 mt-7 mb-3"> <div class="d-grid gap-2 mt-5 mb-5">
<BackBtn class=" w-50" @click="handleback"/> <button type="button" @click="handleNewPassword" class="btn btn-primary">확인</button>
<SaveBtn class="w-50" @click="handleNewPassword" />
</div> </div>
<p v-if="pwErrMsg" class="invalid-feedback d-block mb-0">{{ pwErrMsg }}</p>
</div> </div>
</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 { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useToastStore } from '@s/toastStore'; import { useToastStore } from '@s/toastStore';
import UserFormInput from '@c/input/UserFormInput.vue'; import UserFormInput from '@c/input/UserFormInput.vue';
import FormSelect from '../input/FormSelect.vue'; import FormSelect from '../input/FormSelect.vue';
import BackBtn from '@c/button/BackBtn.vue';
import SaveBtn from '../button/SaveBtn.vue';
const router = useRouter(); const router = useRouter();
const toastStore = useToastStore(); const toastStore = useToastStore();
@ -99,8 +94,6 @@
const birth = ref(''); const birth = ref('');
const pwhint = ref(''); const pwhint = ref('');
const pwhintRes = ref(''); const pwhintRes = ref('');
const userCheckMsg = ref("");
const pwErrMsg = ref("");
const idAlert = ref(false); const idAlert = ref(false);
const birthAlert = ref(false); const birthAlert = ref(false);
@ -113,31 +106,17 @@
const passwordAlert = ref(false); const passwordAlert = ref(false);
const passwordcheckAlert = ref(false); const passwordcheckAlert = ref(false);
const passwordMismatch = ref(false); const passwordcheckErrorAlert = ref(false);
const { pwhintList } = commonApi({ const { pwhintList } = commonApi();
loadPwhint: true,
});
const handleIdChange = value => { const handleIdChange = value => {
id.value = value; id.value = value;
idAlert.value = false; idAlert.value = false;
}; };
const handleback = () => {
router.push('/login');
}
//
watch([password, passwordcheck], () => {
if (passwordcheck.value !== '') {
checkPw();
}
});
// , , , member input // , , , member input
const handleSubmit = async () => { const handleSubmit = async () => {
userCheckMsg.value = '';
idAlert.value = id.value.trim() === ''; idAlert.value = id.value.trim() === '';
pwhintResAlert.value = pwhintRes.value.trim() === ''; pwhintResAlert.value = pwhintRes.value.trim() === '';
birthAlert.value = birth.value.trim() === ''; birthAlert.value = birth.value.trim() === '';
@ -156,30 +135,26 @@
if (response.status === 200 && response.data.data === true) { if (response.status === 200 && response.data.data === true) {
resetForm.value = true; resetForm.value = true;
} else { } else {
userCheckMsg.value = '입력하신 정보와 일치하는 회원이 없습니다.'; toastStore.onToast('일치하는 정보가 없습니다.', 'e');
return;
} }
}; };
const checkPw = () => { const checkPw = async () => {
if (password.value !== passwordcheck.value) { if (password.value !== passwordcheck.value) {
passwordcheckError.value = '비밀번호가 일치하지 않습니다.'; passwordcheckError.value = '비밀번호가 일치하지 않습니다.';
passwordMismatch.value = true; passwordcheckErrorAlert.value = true;
} else { } else {
passwordcheckError.value = ''; passwordcheckError.value = '';
passwordMismatch.value = false; passwordcheckErrorAlert.value = false;
} }
}; };
// //
const handleNewPassword = async () => { const handleNewPassword = async () => {
pwErrMsg.value = '';
passwordAlert.value = password.value.trim() === ''; passwordAlert.value = password.value.trim() === '';
passwordcheckAlert.value = passwordcheck.value.trim() === ''; passwordcheckAlert.value = passwordcheck.value.trim() === '';
checkPw(); if (passwordAlert.value || passwordcheckAlert.value || passwordcheckErrorAlert.value) {
if (passwordAlert.value || passwordcheckAlert.value || passwordMismatch.value) {
return; return;
} }
@ -189,7 +164,7 @@
}); });
if (checkResponse.data.data === false) { if (checkResponse.data.data === false) {
pwErrMsg.value = '기존 비밀번호와 동일한 비밀번호로 변경할 수 없습니다.'; toastStore.onToast('기존 비밀번호와 동일한 비밀번호로 변경할 수 없습니다.', 'e');
return; return;
} }
@ -202,5 +177,6 @@
toastStore.onToast('비밀번호가 재설정 되었습니다.', 's'); toastStore.onToast('비밀번호가 재설정 되었습니다.', 's');
router.push('/login'); router.push('/login');
} }
}; };
</script> </script>

View File

@ -1,5 +1,4 @@
<template> <template>
<form @submit.prevent="handleSubmit">
<div class="col-xl-12"> <div class="col-xl-12">
<UserFormInput title="아이디" name="id" :is-alert="idAlert" :useInputGroup="true" @update:data="handleIdChange" :value="id" /> <UserFormInput title="아이디" name="id" :is-alert="idAlert" :useInputGroup="true" @update:data="handleIdChange" :value="id" />
@ -13,8 +12,7 @@
/> />
<div class="d-grid gap-2 mt-7 mb-5"> <div class="d-grid gap-2 mt-7 mb-5">
<button type="submit" class="btn btn-primary">로그인</button> <button type="submit" @click="handleSubmit" class="btn btn-primary">로그인</button>
<p v-if="errorMessage" class="invalid-feedback d-block mb-0">{{ errorMessage }}</p>
</div> </div>
<div class="mb-3 d-flex justify-content-around"> <div class="mb-3 d-flex justify-content-around">
@ -23,15 +21,15 @@
<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>
</template> </template>
<script setup> <script setup>
import $api from '@api'; import $api from '@api';
import router from '@/router'; import router from '@/router';
import { useRoute } from 'vue-router';
import { ref } from 'vue'; import { 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';
@ -41,9 +39,9 @@
const idAlert = ref(false); const idAlert = ref(false);
const passwordAlert = ref(false); const passwordAlert = ref(false);
const remember = ref(false); const remember = ref(false);
const errorMessage = ref("");
const userStore = useUserInfoStore(); const userStore = useUserInfoStore();
const route = useRoute();
const handleIdChange = value => { const handleIdChange = value => {
id.value = value; id.value = value;
@ -56,7 +54,6 @@
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
errorMessage.value = '';
idAlert.value = id.value.trim() === ''; idAlert.value = id.value.trim() === '';
passwordAlert.value = password.value.trim() === ''; passwordAlert.value = password.value.trim() === '';
@ -64,23 +61,16 @@
return; return;
} }
$api.post('user/login', { $api.post('user/login', {
loginId: id.value, loginId: id.value,
password: password.value, password: password.value,
remember: remember.value, remember: remember.value,
}, { headers: { isLogin: true } }) }, { headers: { 'X-Page-Route': route.path } })
.then(async res => { .then(res => {
// if (res.status === 200) {
if (res.data.code > 10000) { userStore.userInfo();
//
errorMessage.value = res.data.message;
return;
}
//
await userStore.userInfo();
router.push('/'); router.push('/');
}) }
});
}; };
</script> </script>

View File

@ -5,7 +5,7 @@
for="profilePic" for="profilePic"
class="rounded-circle m-auto ui-bg-cover position-relative cursor-pointer" class="rounded-circle m-auto ui-bg-cover position-relative cursor-pointer"
id="profileLabel" id="profileLabel"
style="width: 100px; height: 100px; background-image: url(img/avatars/default-Profile.jpg); background-repeat: no-repeat; background-size: cover;" style="width: 100px; height: 100px; background-image: url(public/img/avatars/default-Profile.jpg); background-repeat: no-repeat;"
> >
</label> </label>
@ -25,7 +25,6 @@
@update:alert="idAlert = $event" @update:alert="idAlert = $event"
@blur="checkIdDuplicate" @blur="checkIdDuplicate"
:value="id" :value="id"
@keypress="noSpace"
/> />
<span v-if="idError" class="invalid-feedback d-block">{{ idError }}</span> <span v-if="idError" class="invalid-feedback d-block">{{ idError }}</span>
@ -38,9 +37,7 @@
@update:data="password = $event" @update:data="password = $event"
@update:alert="passwordAlert = $event" @update:alert="passwordAlert = $event"
:value="password" :value="password"
@keypress="noSpace"
/> />
<span v-if="passwordError" class="invalid-feedback d-block">{{ passwordError }}</span>
<UserFormInput <UserFormInput
title="비밀번호 확인" title="비밀번호 확인"
@ -50,8 +47,8 @@
:is-alert="passwordcheckAlert" :is-alert="passwordcheckAlert"
@update:data="passwordcheck = $event" @update:data="passwordcheck = $event"
@update:alert="passwordcheckAlert = $event" @update:alert="passwordcheckAlert = $event"
@blur="checkPw"
:value="passwordcheck" :value="passwordcheck"
@keypress="noSpace"
/> />
<span v-if="passwordcheckError" class="invalid-feedback d-block">{{ passwordcheckError }}</span> <span v-if="passwordcheckError" class="invalid-feedback d-block">{{ passwordcheckError }}</span>
@ -85,7 +82,6 @@
@update:data="name = $event" @update:data="name = $event"
@update:alert="nameAlert = $event" @update:alert="nameAlert = $event"
:value="name" :value="name"
@keypress="noSpace"
class="me-2 w-50" class="me-2 w-50"
/> />
@ -96,14 +92,11 @@
:is-row="false" :is-row="false"
:is-label="true" :is-label="true"
:is-common="true" :is-common="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
@ -125,7 +118,6 @@
:is-row="false" :is-row="false"
:is-label="true" :is-label="true"
:is-common="true" :is-common="true"
:is-mbti="true"
:data="mbtiList" :data="mbtiList"
@update:data="mbti = $event" @update:data="mbti = $event"
class="w-50" class="w-50"
@ -140,7 +132,6 @@
@update:data="handleAddressUpdate" @update:data="handleAddressUpdate"
@update:alert="addressAlert = $event" @update:alert="addressAlert = $event"
:value="address" :value="address"
:disabled="true"
/> />
<UserFormInput <UserFormInput
@ -148,14 +139,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"
:maxlength="11" :maxlength="11"
:value="phone" :value="phone"
@keypress="onlyNumber"
@input="inputEvent"
/> />
<span v-if="phoneError" class="invalid-feedback d-block">{{ phoneError }}</span>
<div class="d-flex mt-5"> <div class="d-flex mt-5">
<RouterLink type="button" class="btn btn-secondary me-2 w-50" to="/login">취소</RouterLink> <RouterLink type="button" class="btn btn-secondary me-2 w-50" to="/login">취소</RouterLink>
@ -166,9 +154,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 +172,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('');
@ -194,9 +181,7 @@
const detailAddress = ref(''); const detailAddress = ref('');
const postcode = ref(''); // const postcode = ref(''); //
const phone = ref(''); const phone = 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
@ -204,23 +189,16 @@
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 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 +228,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 +238,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 = '이미 사용 중인 아이디입니다.';
@ -279,105 +251,33 @@
} }
}; };
//
const checkPhoneDuplicate = async () => {
const response = await $api.get(`/user/checkPhone?memberTel=${phone.value}`);
if (!response.data.data) {
phoneErrorAlert.value = true;
phoneError.value = '이미 사용 중인 전화번호입니다.';
} else {
phoneErrorAlert.value = false;
phoneError.value = '';
}
};
// , 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) {
if (response.data.data) {
colorErrorAlert.value = true;
colorError.value = '이미 사용 중인 색상입니다.';
} else {
colorErrorAlert.value = false;
colorError.value = '';
}
};
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 = '비밀번호가 일치하지 않습니다.'; passwordcheckError.value = '비밀번호가 일치하지 않습니다.';
passwordcheckErrorAlert.value = true; passwordcheckErrorAlert.value = true;
} else { } else {
passwordcheckError.value = ''; passwordcheckError.value = '';
passwordcheckErrorAlert.value = false; 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 +288,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 +297,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) {
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);
@ -467,3 +325,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 userList"
: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,93 +13,33 @@
: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;`"
@error="$event.target.src = '/img/icons/icon.png'"
alt="user" alt="user"
/> />
</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 } 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
const sortedUserList = computed(() => {
return [...userList.value].sort((a, b) => {
const aDisabled = isUserDisabled(a);
const bDisabled = isUserDisabled(b);
//
if (!aDisabled && bDisabled) return -1;
if (aDisabled && !bDisabled) return 1;
return 0;
});
});
// 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 +50,9 @@ const fetchProjectParticipation = async () => {
...user, ...user,
PROJCTYON: projectMembers.find(pm => pm.MEMBERSEQ === user.MEMBERSEQ)?.PROJCTYON ?? '1' PROJCTYON: projectMembers.find(pm => pm.MEMBERSEQ === user.MEMBERSEQ)?.PROJCTYON ?? '1'
})); }));
//
originalDisabledUsers.value = userList.value
.filter(user => user.PROJCTYON === '0')
.map(user => user.MEMBERSEQ);
emitUserListUpdate();
} }
} }
}; };
//
const fetchUserProjectPeriods = async () => {
if (props.projctSeq) {
try {
const response = await $api.get(`project/period/${props.projctSeq}`);
if (response.status === 200) {
userProjectPeriods.value = response.data.data;
}
} catch (error) {
console.error('프로젝트 참여 기간 조회 실패:', error);
}
}
};
// projctSeq
watch(() => props.projctSeq, async (newVal, oldVal) => {
if (newVal !== oldVal) {
await fetchProjectParticipation();
await fetchUserProjectPeriods();
}
}, { immediate: true });
// //
onMounted(async () => { onMounted(async () => {
@ -150,57 +61,44 @@ onMounted(async () => {
if (props.projctSeq) { if (props.projctSeq) {
await fetchProjectParticipation(); await fetchProjectParticipation();
await fetchUserProjectPeriods();
} else {
// projctSeq , emit
emitUserListUpdate();
} }
nextTick(() => { nextTick(() => {
initTooltips();
});
});
//
const initTooltips = () => {
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]'); const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach((tooltip) => { tooltips.forEach((tooltip) => {
if (tooltip._tooltip) {
tooltip._tooltip.dispose();
}
new bootstrap.Tooltip(tooltip); new bootstrap.Tooltip(tooltip);
}); });
}; });
});
// //
const isUserDisabled = (user) => { const isUserDisabled = (user) => {
return props.projctSeq ? user.PROJCTYON === '0' : user.disabled; return props.projctSeq ? user.PROJCTYON === '0' : user.disabled;
}; };
// ( API ) // / DB
const toggleDisable = (index) => { const toggleDisable = async (index) => {
if (props.showOnlyActive) return; const user = userList.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
if (props.projctSeq) { ? user.PROJCTYON === '1'
user.PROJCTYON = user.PROJCTYON === '1' ? '0' : '1'; : !user.disabled;
} else {
user.disabled = !user.disabled;
}
if (props.projctSeq) {
const response = await $api.patch('project/updateYon', {
memberSeq: user.MEMBERSEQ,
projctSeq: props.projctSeq,
projctYon: newParticipationStatus ? '0' : '1'
});
if (response.status === 200) {
user.PROJCTYON = newParticipationStatus ? '0' : '1';
}
} else {
user.disabled = newParticipationStatus;
emitUserListUpdate(); emitUserListUpdate();
} }
}
}; };
// emit // emit
@ -210,51 +108,7 @@ const emitUserListUpdate = () => {
emit('user-list-update', { activeUsers, disabledUsers }); emit('user-list-update', { activeUsers, disabledUsers });
}; };
//
const formatDate = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
//
const getTooltipTitle = (user) => { const getTooltipTitle = (user) => {
const userName = user.MEMBERSEQ === userStore.userInfo.id ? '나' : user.MEMBERNAM; return user.MEMBERSEQ === userStore.userInfo.id ? '나' : user.MEMBERNAM;
if (props.projctSeq) {
const periodInfo = userProjectPeriods.value.find(p => p.MEMBERSEQ === user.MEMBERSEQ);
if (periodInfo) {
return `${formatDate(periodInfo.userStartDate)} ~ ${formatDate(periodInfo.userEndDate)}`;
}
}
return userName;
}; };
const hasUserChanges = () => {
if (!props.projctSeq) return false;
const currentDisabledUserIds = userList.value
.filter(user => user.PROJCTYON === '0')
.map(user => user.MEMBERSEQ);
//
if (currentDisabledUserIds.length !== originalDisabledUsers.value.length) {
return true;
}
// ID
return currentDisabledUserIds.some(id => !originalDisabledUsers.value.includes(id)) ||
originalDisabledUsers.value.some(id => !currentDisabledUserIds.includes(id));
};
// expose
defineExpose({
resetSelection,
fetchProjectParticipation,
hasUserChanges
});
</script> </script>

View File

@ -1,35 +1,28 @@
<template> <template>
<ul class="row gx-2 mb-0 list-inline "> <div class="card-body d-flex justify-content-center">
<ul class="list-unstyled d-flex align-items-center gap-5 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
v-if="user.MEMBERSEQ === employeeId" class="rounded-circle"
src="/img/icons/Crown.png"
alt="Crown"
class="start-50 translate-middle crown-icon"
/>
<img
class="rounded-circle object-fit-cover"
:src="getUserProfileImage(user.MEMBERPRF)" :src="getUserProfileImage(user.MEMBERPRF)"
alt="user" alt="user"
:style="getDynamicStyle(user)" :style="getDynamicStyle(user)"
@error="setDefaultImage" @error="setDefaultImage"
@load="showImage" @load="showImage"
/> />
</div> <span class="remaining-vacation">
<span class="mt-2 text-sm-center d-block fs-6 remaining-vacation">
{{ remainingVacationData[user.MEMBERSEQ] || 0 }} {{ remainingVacationData[user.MEMBERSEQ] || 0 }}
</span> </span>
</li> </li>
</ul> </ul>
</div>
</template> </template>
<script setup> <script setup>
@ -39,7 +32,10 @@ import { useUserStore } from "@s/userList";
import $api from "@api"; import $api from "@api";
defineEmits(["profileClick"]); defineEmits(["profileClick"]);
defineProps({ remainingVacationData: Object });
defineProps({
remainingVacationData: Object,
});
const userStore = useUserInfoStore(); const userStore = useUserInfoStore();
const userListStore = useUserStore(); const userListStore = useUserStore();
@ -49,60 +45,82 @@ const baseUrl = $api.defaults.baseURL.replace(/api\/$/, "");
const defaultProfile = "/img/icons/icon.png"; const defaultProfile = "/img/icons/icon.png";
const employeeId = ref(null); const employeeId = ref(null);
const userColors = ref({}); const userColors = ref({});
const windowWidth = ref(window.innerWidth);
const updateWindowWidth = () => {
windowWidth.value = window.innerWidth;
};
onMounted(async () => { onMounted(async () => {
window.addEventListener("resize", updateWindowWidth);
await userStore.userInfo(); await userStore.userInfo();
employeeId.value = userStore.user?.id ?? null; if (userStore.user) {
employeeId.value = userStore.user.id;
} else {
console.error("❌ 로그인한 사용자 정보를 불러오지 못했습니다.");
}
await userListStore.fetchUserList(); await userListStore.fetchUserList();
userList.value = userListStore.userList; userList.value = userListStore.userList;
//
userList.value.forEach(user => { userList.value.forEach(user => {
userColors.value[user.MEMBERSEQ] = user.usercolor || "#ccc"; userColors.value[user.MEMBERSEQ] = user.usercolor || "#ccc";
}); });
nextTick(() => { nextTick(() => {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(tooltip => { const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach(tooltip => {
new bootstrap.Tooltip(tooltip); new bootstrap.Tooltip(tooltip);
}); });
}); });
}); });
const sortedUserList = computed(() => { const sortedUserList = computed(() => {
if (!employeeId.value) return []; if (!employeeId.value) return userList.value; //
// ( )
const myProfile = userList.value.find(user => user.MEMBERSEQ === employeeId.value); const 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) => const getUserProfileImage = (profilePath) => {
profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile; 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";
};
//
const profileSize = computed(() => {
const totalUsers = userList.value.length;
if (totalUsers <= 7) return "100px"; // 7
if (totalUsers <= 10) return "80px"; // ~10
if (totalUsers <= 20) return "60px"; // ~20
return "40px"; // 20
});
//
const getDynamicStyle = (user) => {
return {
width: profileSize.value,
height: profileSize.value,
borderWidth: "4px",
borderColor: user.usercolor || "#ccc", borderColor: user.usercolor || "#ccc",
borderStyle: "solid", borderStyle: "solid",
}); };
};
</script> </script>
<style scoped> <style scoped>
.crown-icon { /* 남은 연차 개수 스타일 */
width: 90%; .remaining-vacation {
height: 70%; display: block;
z-index: 0; text-align: center;
top: -7% font-size: 14px;
color: #333;
margin-top: 5px;
} }
</style> </style>

View File

@ -1,73 +1,55 @@
<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">
<h5 class="card-title mb-1"> <h5 class="card-title mb-1"><div class="list-group-item list-group-item-action d-flex align-items-center cursor-pointer">
<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 class="add-btn">
<!-- 투표완료시 --> <!-- 투표완료시 -->
</div> <i class="bx bxs-check-circle link-success"></i>
<div class="add-btn d-flex align-items-center">
<!-- 투표작성자만 수정/삭제/종료 가능 --> <!-- 투표작성자만 수정/삭제/종료 가능 -->
<div v-if="userStore.user.id === data.localVote.LOCVOTREG">
<button <button
v-if="!data.localVote.LOCVOTDDT" v-if="userStore.user.id === data.localVote.LOCVOTREG"
type="button" type="button" class="bx btn btn-danger">종료</button>
class="btn btn-label-danger btn-icon m-1" <EditBtn />
@click="endBtn(data.localVote.LOCVOTSEQ)" <DeleteBtn />
><i class="bx bx-power-off"></i>
</button>
<DeleteBtn v-if="!data.localVote.LOCVOTDDT" @click="voteDelete(data.localVote.LOCVOTSEQ)" />
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</h5> </h5>
<h5 class="mb-0">{{ data.localVote.LOCVOTTTL }} <h5>{{ data.localVote.LOCVOTTTL }}</h5>
<i v-if="yesVotetotal != '0'" class="bx bxs-check-circle link-success"></i> <div class="mb-1">{{ data.localVote.formatted_LOCVOTRDT }} ~ {{ data.localVote.formatted_LOCVOTEDT }}</div>
</h5> <!-- 투표완료시-->
<small >{{ data.localVote.formatted_LOCVOTRDT }} ~ {{ data.localVote.formatted_LOCVOTEDT }}</small> <button v-if="data.yesVotetotal > 0" class="btn btn-primary btn-sm" >재투표</button>
<!-- 투표안했을시--> <!-- 투표안했을시-->
<div v-if="data.localVote.LOCVOTDDT && voteResult == 0">
<small class="text-primary text-uppercase">투표 결과없음 (😂아무도 투표하지 않았습니다)</small>
</div>
<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">
<vote-result-list :data="topVoters" @randomList="randomList" :randomResultNum="data.localVote.LOCVOTRES" :locvotreg="data.localVote.LOCVOTREG" />
</div>
<!-- 투표완/미완 인원 --> <!-- 투표완/미완 인원 -->
<vote-user-list <vote-user-list
:data="data.voteMembers"/> :data="data.voteMembers"/>
</div> <!-- 투표 결과 -->
</div> <vote-result-list />
<div v-else class="card-body disabled-class">
<h5>{{ data.localVote.LOCVOTTTL }}</h5>
삭제된 투표입니다.
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { 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';
@ -81,78 +63,19 @@ const props = defineProps({
type: Object, type: Object,
required: true, required: true,
}, },
randomresult: {
type: Boolean,
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 offset = new Date().getTimezoneOffset() * 60000
const today = new Date(Date.now() - offset);
const currentDate = today.toISOString().substring(0,16);
const voteEndDate = props.data.localVote.LOCVOTEDT.substring(0,16);
//
const isVoteEnded = computed(() => {
return currentDate > voteEndDate;
});
const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','randomList','voteDelete','updateVote']);
onMounted(() => {
if (isVoteEnded.value && !props.data.localVote.LOCVOTDDT) {
emit('voteEnded', { id: props.data.localVote.LOCVOTSEQ });
}
checkVoteCompletion();
});
//
watch(() => props.data.localVote.total_voted, () => {
checkVoteCompletion();
});
// const userStore = useUserInfoStore();
const checkVoteCompletion = () => { const emit = defineEmits(['addContents','checkedNames']);
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)
} }
const checkedNames = (numList) =>{ const checkedNames = (numList) =>{
emit('checkedNames',numList); emit('checkedNames',numList);
} }
const endBtn = (voteid) =>{
emit('endVoteId',voteid);
}
const voteDelete = (voteid) =>{
emit('voteDelete',voteid);
}
const randomList = (random) =>{
emit('randomList',random,props.data.localVote.LOCVOTSEQ);
}
const updateVote = (voteid) =>{
emit('updateVote',voteid);
}
</script> </script>
<style scoped> <style scoped>
.disabled-class {
pointer-events: none; /* 클릭 방지 */
opacity: 0.5; /* 흐리게 표시 */
}
</style> </style>

View File

@ -1,73 +1,65 @@
<template> <template>
<div class="card-text"> <div class="card-text">
<div class="demo-inline-spacing mt-4">
<!-- 투표리스트 --> <!-- 투표리스트 -->
<div v-for="(item, index) in data" :key="index" class="mt-3"> <div v-for="(item, index) in data"
:key="index">
<vote-card-check-list <vote-card-check-list
:data="item" :data="item"
:multiIs = voteInfo.LOCVOTMUL :multiIs = voteInfo.LOCVOTMUL
:selectedValues="checkedNames" :selectedValues="checkedNames"
@update:selectedValues="updateCheckedNames" @update:selectedValues="updateCheckedNames"
/> />
<div v-if="voteInfo.LOCVOTADD ==='1' && index === data.length - 1"> <div v-if="voteInfo.LOCVOTADD ==='1' && index === data.length - 1" class="d-flex align-items-center">
<div v-for="(item, index) in itemList" :key="index" class=" mt-2"> <div class="d-flex flex-column gap-2">
<div v-for="(item, index) in itemList" :key="index" class="d-flex align-items-center">
<form-input <form-input
class="flex-grow-1 me-2"
:title="'항목 ' + (index + data.length + 1)" :title="'항목 ' + (index + data.length + 1)"
:name="'content' + index" :name="'content' + index"
:is-essential="false" :is-essential="false"
:is-alert="contentAlerts[index]" :is-alert="contentAlerts[index]"
v-model="item.content" v-model="item.content"
:is-btn="true"
@keyup="ValidHandler('content' + (index + 1))"
>
<template v-slot:append>
<delete-btn @click="removeItem(index)" />
</template>
</form-input>
<form-input
:title="'URL ' + (index + data.length + 1)"
:name="'url' + index"
v-model="item.url"
:is-essential="false"
class="mb-1"
:maxlength="maxLength"
/> />
<link-input v-model="item.url" />
<delete-btn @click="removeItem(index)" class="ms-2" />
</div> </div>
<div class="d-flex justify-content align-items-center mt-3"> <div class="mb-4 d-flex justify-content">
<plus-btn @click="addItem" :disabled=" total >= 10" /> <plus-btn @click="addItem" :disabled="total >= 10" class="mb-3" />
<button class="btn btn-primary btn-icon m-1" @click="addContentSave(item.LOCVOTSEQ ,index)" :disabled="isSaveDisabled"> <button class="btn btn-primary btn-icon mb-3" @click="addContentSave(item.LOCVOTSEQ)" :disabled="isSaveDisabled">
<i class="bx bx-check"></i> <i class="bx bx-check"></i>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<div class="d-flex">
<save-btn class="mt-2 ms-auto" @click="selectVote"/> </div>
</div> </div>
</div> </div>
<button class="btn btn-primary btn-sm" @click="selectVote">투표하기</button>
</template> </template>
<script setup> <script setup>
import $api from '@api'; import $api from '@api';
import PlusBtn from '@c/button/PlusBtn.vue'; import PlusBtn from '@c/button/PlusBtn.vue';
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,44 +77,18 @@ const props = defineProps({
}); });
const emit = defineEmits(['addContents','checkedNames']); const emit = defineEmits(['addContents','checkedNames']);
// //
const addContentSave = (voteId,index) =>{ const addContentSave = (voteId) =>{
let valid = true; emit('addContents',itemList.value,voteId);
const filteredItemList = itemList.value.filter(item => item.content && item.content.trim() !== '');
itemList.value.forEach((item, index) => {
if (!item.content.trim() && item.url.trim()) {
contentAlerts.value[index] = true;
valid = false;
} else {
contentAlerts.value[index] = false;
}
});
if(valid){
emit('addContents',filteredItemList,voteId);
itemList.value = [{ content: "", url: "" }]; 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

@ -3,15 +3,9 @@
<card <card
@addContents="addContents" @addContents="addContents"
@checkedNames="checkedNames" @checkedNames="checkedNames"
@endVoteId="endVoteId"
@voteEnded="voteEnded"
@voteDelete="voteDelete"
@randomList="randomList"
@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>
@ -22,30 +16,14 @@ const props = defineProps({
type: Array, type: Array,
required: true, required: true,
}, },
}); });
const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','voteDelete','randomList']); const emit = defineEmits(['addContents','checkedNames']);
const addContents = (itemList ,voteId) =>{ const addContents = (itemList ,voteId) =>{
emit('addContents',itemList ,voteId); emit('addContents',itemList ,voteId);
} }
const checkedNames = (numList) =>{ const checkedNames = (numList) =>{
emit('checkedNames',numList); emit('checkedNames',numList);
} }
const endVoteId = (VoteId) =>{
emit('endVoteId',VoteId);
}
const voteEnded = (id) =>{
emit('voteEnded',id);
}
const voteDelete = (id) =>{
emit('voteDelete',id);
}
const updateVote = (id) =>{
emit('updateVote',id);
}
const randomList = (randomList , id) =>{
emit('randomList',randomList,id);
}
</script> </script>

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,10 +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'"
alt="user" alt="user"
/> />
</li> </li>

View File

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

View File

@ -7,10 +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'"
alt="user" alt="user"
/> />
</li> </li>

View File

@ -1,34 +1,32 @@
<template> <template>
<div class="mb-2 row"> <div class="position-relative me-2">
<!-- 링크 아이콘 --> <i class="bx bx-link-alt" @click="togglePopover"></i>
<label for="name" class="col-md-2 col-form-label"> <!-- 링크 팝업 -->
<div class="d-flex align-items-center"> <div
<!-- 링크 아이콘 --> v-if="isPopoverVisible"
<i class="bx bx-link-alt me-2" @click="togglePopover"></i> class="popover bs-popover-auto fade show d-flex align-items-center"
<!-- 등록된 링크, 입력창이 보이지 않고 등록된 링크만 보일 --> role="tooltip"
<span v-if="isLinkSaved && !isPopoverVisible" class="ms-2"> :style="popoverStyle"
<a :href="formattedLink" class="d-inline-block text-truncate" target="_blank" rel="noopener noreferrer"> >
{{ link }} <div class="popover-arrow"></div>
</a>
</span>
</div>
</label>
<!-- 링크 입력창 (옆으로 나오게) -->
<div v-if="isPopoverVisible" class="col-md-10">
<div class="d-flex align-items-center">
<input <input
v-model="link" v-model="link"
placeholder="URL을 입력해주세요" placeholder="URL을 입력해주세요"
class="form-control me-2" class="form-control me-2 flex-grow-1"
style="min-width: 200px;"
/> />
<save-btn class="btn-icon" @click="saveLink"/> <button type="button" class="btn btn-sm btn-primary ms-2" @click="saveLink">
등록
</button>
</div> </div>
<!-- 등록된 링크 표시 -->
<div v-if="link" class="mt-1">
<a :href="formattedLink" target="_blank" rel="noopener noreferrer">{{ link }}</a>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import SaveBtn from '@c/button/SaveBtn.vue'
import { ref, computed } from "vue"; import { ref, computed } from "vue";
const props = defineProps({ const props = defineProps({
@ -38,33 +36,45 @@ const emit = defineEmits(["update:modelValue"]);
const isPopoverVisible = ref(false); const isPopoverVisible = ref(false);
const link = ref(props.modelValue || ""); const link = ref(props.modelValue || "");
const isLinkSaved = ref(false); // Track if the link has been saved const popoverStyle = ref({});
const formattedLink = computed(() => { const formattedLink = computed(() => {
return link.value.startsWith("http") ? link.value : "http://" + link.value; return link.value.startsWith("http") ? link.value : "http://" + link.value;
}); });
const togglePopover = () => { const togglePopover = (event) => {
const buttonRect = event.target.getBoundingClientRect();
const parentRect = event.target.parentElement.getBoundingClientRect();
popoverStyle.value = {
position: "absolute",
top: `${buttonRect.bottom - parentRect.top + 5}px`,
left: `${buttonRect.left - parentRect.left}px`,
zIndex: "1050",
display: "flex",
alignItems: "center",
};
isPopoverVisible.value = !isPopoverVisible.value; isPopoverVisible.value = !isPopoverVisible.value;
}; };
const saveLink = () => { const saveLink = () => {
emit("update:modelValue", link.value); emit("update:modelValue", link.value);
isLinkSaved.value = true; // Set the link as saved
isPopoverVisible.value = false; isPopoverVisible.value = false;
}; };
</script> </script>
<style scoped> <style scoped>
.popover-container { .popover {
display: flex; max-width: 300px;
align-items: center; border-radius: 6px;
gap: 8px; /* 아이콘과 입력창 간격 조정 */ padding: 5px;
} }
a {
max-width: 500px; /* 원하는 너비로 조정 */ .popover-arrow {
white-space: nowrap; position: absolute;
overflow: hidden; top: -5px;
text-overflow: ellipsis; left: 50%;
transform: translateX(-50%);
} }
</style> </style>

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