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_SERVER = https://192.168.0.251:10300/
VITE_API_URL = https://192.168.0.251:10300/api/
VITE_TEST_URL = https://192.168.0.251:10300/test/
VITE_SERVER_IMG_URL = https://192.168.0.251:10300/upload/img/
VITE_KAKAO_MAP_KEY=6f092e8f45ee81186bb6d8408f66a492
# VITE_FILE_URL = http://localhost:10325/ms/
# VITE_API_URL = http://localhost:10325/api/
VITE_API_URL = http://localhost:10325/test/

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" />
<!-- Icons -->
<link rel="stylesheet" href="/vendor/fonts/boxicons.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
'''
}
}
}
}

8958
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,52 +1,49 @@
{
"name": "front",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --mode dev",
"mine": "vite --host 0.0.0.0 --mode mine",
"build": "vite build --mode dev",
"preview": "vite preview",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@popperjs/core": "^2.11.8",
"@tinymce/tinymce-vue": "^5.1.1",
"@vueup/vue-quill": "^1.2.0",
"@vueuse/core": "^13.0.0",
"axios": "^1.7.9",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"dayjs": "^1.11.13",
"dompurify": "^3.2.3",
"flatpickr": "^4.6.13",
"front": "file:",
"heic2any": "^0.0.4",
"pinia": "^2.2.6",
"pinia-plugin-persist": "^1.0.0",
"quill": "^2.0.3",
"upload-images-converter": "^2.0.2",
"vite-plugin-mkcert": "^1.17.6",
"vue": "^3.5.13",
"vue-flatpickr-component": "^11.0.5",
"vue-router": "^4.4.5",
"vue3-kakao-maps": "^2.3.10"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-prettier": "^10.1.0",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0",
"prettier": "^3.3.3",
"vite": "^5.4.10",
"vite-plugin-inspect": "^0.8.9",
"vite-plugin-vue-devtools": "^7.6.5"
}
"name": "front",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0 --mode dev",
"build": "vite build --mode prod",
"preview": "vite preview",
"lint": "eslint . --fix",
"format": "prettier --write src/"
},
"dependencies": {
"@fullcalendar/core": "^6.1.15",
"@fullcalendar/daygrid": "^6.1.15",
"@fullcalendar/interaction": "^6.1.15",
"@fullcalendar/vue3": "^6.1.15",
"@popperjs/core": "^2.11.8",
"@tinymce/tinymce-vue": "^5.1.1",
"@vueup/vue-quill": "^1.2.0",
"axios": "^1.7.9",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"dayjs": "^1.11.13",
"dompurify": "^3.2.3",
"flatpickr": "^4.6.13",
"front": "file:",
"heic2any": "^0.0.4",
"pinia": "^2.2.6",
"pinia-plugin-persist": "^1.0.0",
"quill": "^2.0.3",
"upload-images-converter": "^2.0.2",
"vite-plugin-mkcert": "^1.17.6",
"vue": "^3.5.13",
"vue-flatpickr-component": "^11.0.5",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@eslint/js": "^9.14.0",
"@vitejs/plugin-vue": "^5.2.1",
"@vue/eslint-config-prettier": "^10.1.0",
"eslint": "^9.14.0",
"eslint-plugin-vue": "^9.30.0",
"prettier": "^3.3.3",
"vite": "^5.4.10",
"vite-plugin-inspect": "^0.8.9",
"vite-plugin-vue-devtools": "^7.6.5"
}
}

View File

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

View File

@ -1,834 +1,89 @@
/* 여기에 light css 작성 */
.opacity-50 {
opacity: 0.5;
.display-block {
display: block !important;
}
/* board */
.board-content img {
max-width: 100% !important;
height: auto !important;
display: block;
object-fit: contain;
/* 게시판리스트 */
.bg-label-gray td {
color: #DC3545 !important;
}
.board-content table {
max-width: 100%;
overflow-x: auto;
display: block;
}
.btn.big {
width: 70px;
height: 70px;
font-size: 18px;
}
@keyframes new {
0% {
background-color: #ffcc00;
}
50% {
background-color: red;
}
100% {
background-color: #ffcc00;
}
}
.box-new {
animation: new 1s infinite; /* 1초마다 반복 */
}
/* board end */
/* Qeditor */
.ql-editor {
min-height: 300px;
font-family: 'Nanum Gothic', sans-serif;
}
/* Qeditor end */
/* 에러페이지 */
/* 전체 화면을 덮는 스타일 */
.error-page {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: #fff;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
color: #000;
font-family: 'Poppins', sans-serif;
z-index: 9999 !important;
}
/* 오류 메시지 컨텐츠 */
.error-content {
text-align: center;
animation: fadeIn 0.8s ease-in-out;
}
/* 에러 이미지 */
.error-image {
width: 280px; /* 이미지 크기 */
margin-bottom: 20px;
}
/* 에러 코드 스타일 */
.error-content h1 {
font-size: 6rem;
font-weight: bold;
color: #ff8c00; /* 오렌지 */
text-shadow: 2px 2px 8px rgba(255, 140, 0, 0.3);
margin-bottom: 60px;
}
/* 홈으로 돌아가기 버튼 */
.home-btn {
display: inline-block;
padding: 10px 28px;
font-size: 1rem;
font-weight: bold;
text-decoration: none;
color: #fff;
background: rgba(105, 108, 255, 0.9);
border-radius: 30px;
transition: 0.3s ease-in-out;
box-shadow: 0 4px 15px rgba(105, 108, 255, 0.5);
}
/* 버튼 호버 효과 */
.home-btn:hover {
background: linear-gradient(90deg, orange, #ff8c00);
box-shadow: 0 0 20px rgba(255, 140, 0, 1);
transform: scale(1.05);
color: #fff;
}
/* 페이드 인 애니메이션 */
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 휴가 */
.fc-daygrid-event {
pointer-events: none !important;
}
/* 이벤트 선 없게 */
.fc-event {
border: none;
}
/* 오전 반차 그래프 (왼쪽 절반) */
.fc-daygrid-event.half-day-am {
width: 50% !important;
height: 8px !important;
border-radius: 2px !important;
font-size: 0px !important;
margin-left: -0.5% !important;
}
/* 오후 반차 그래프 (오른쪽 절반) */
.fc-daygrid-event.half-day-pm {
width: 50% !important;
height: 8px !important;
margin-left: auto !important;
border-radius: 2px !important;
font-size: 0px !important;
margin-right: -0.5% !important;
}
/* 연차 그래프 (풀) */
.fc-daygrid-event.full-day {
width: 100% !important;
height: 8px !important;
margin-left: auto !important;
border-radius: 2px !important;
font-size: 0px !important;
}
/* 공휴일,일요일 색상 */
.fc-day-sun .fc-daygrid-day-number,
.fc-col-header-cell:first-child .fc-col-header-cell-cushion {
color: #ff4500 !important;
}
/* 토요일 색상 */
.fc-day-sat .fc-daygrid-day-number,
.fc-col-header-cell:last-child .fc-col-header-cell-cushion {
color: #6076e0 !important;
}
/* 캘린더 날짜 왼쪽 상단 위치하게 */
.fc-daygrid-day-number {
margin-right: auto;
}
/* 데이트피커 뾰족없게 */
.flatpickr-calendar:before,
.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;
.half-day-buttons {
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 {
filter: grayscale(100%);
}
/* scrollbar 안보이게 */
.scrollbar-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;
gap: 10px;
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);
}
.half-day-buttons .btn.active {
border: 2px solid black;
}
.fc-daygrid-day-frame {
min-height: 80px !important;
max-height: 120px !important;
overflow: hidden !important;
padding-top: 25px !important;
}
.fc-daygrid-day-events {
max-height: 100px !important;
overflow-y: auto !important;
}
.fc-daygrid-event {
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 {
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;
}
align-items: flex-start;
justify-content: flex-start;
width: 100%;
gap: 22px;
}
.fc-daygrid-event.half-day-am {
width: 45% !important;
left: 0 !important;
}
.fc-daygrid-event.half-day-pm {
width: 45% !important;
left: auto !important;
right: 0 !important;
}
.fc-daygrid-event.full-day {
width: 100% !important;
left: 0 !important;
}
.fc-day-sun .fc-daygrid-day-number,
.fc-col-header-cell:first-child .fc-col-header-cell-cushion {
color: #ff4500 !important;
}
.fc-day-sat .fc-daygrid-day-number,
.fc-col-header-cell:last-child .fc-col-header-cell-cushion {
color: #6076e0 !important;
}
.fc-daygrid-day-number {
position: absolute !important;
top: 0px !important;
left: 5px !important;
text-align: left !important;
}
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;
}
.grayscaleImg {
filter: grayscale(100%);
}
/* scrollbar 안보이게 */
.scrollbar-none {
scrollbar-width: none;
}

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');
/* D2Coding 폰트 */
@font-face {
font-family: 'D2Coding';
src: url('/font/D2Coding-Ver1.3.2-20180524-all.ttc') format('truetype');
src: url('/font/D2Coding-Ver1.3.2-20180524-all.ttc') format('ttc');
font-weight: normal;
font-style: normal;
}
/* Consolas 폰트 */
@font-face {
font-family: 'Consolas';
src: url('/font/Consolas.woff') format('font-woff');
src: url('/font/Consolas.woff') format('woff');
font-weight: normal;
font-style: normal;
}
/* 툴바에서 선택 가능한 폰트 */
/* 툴바에서 선택 가능한 폰트 및 크기 스타일 */
.ql-font-nanum-gothic {
font-family: 'Nanum Gothic', sans-serif;
}
@ -175,10 +33,24 @@ body {
}
/* 폰트 크기 스타일 */
.ql-size-12px { font-size: 12px; }
.ql-size-14px { font-size: 14px; }
.ql-size-16px { font-size: 16px; }
.ql-size-18px { font-size: 18px; }
.ql-size-24px { font-size: 24px; }
.ql-size-32px { font-size: 32px; }
.ql-size-48px { font-size: 48px; }
.ql-size-12px {
font-size: 12px;
}
.ql-size-14px {
font-size: 14px;
}
.ql-size-16px {
font-size: 16px;
}
.ql-size-18px {
font-size: 18px;
}
.ql-size-24px {
font-size: 24px;
}
.ql-size-32px {
font-size: 32px;
}
.ql-size-48px {
font-size: 48px;
}

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

View File

@ -15304,12 +15304,12 @@ html:not(.layout-menu-fixed) .menu-inner-shadow {
}
@media (max-width: 1199.98px) {
.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) {
.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 {
@ -15317,12 +15317,12 @@ html:not(.layout-menu-fixed) .menu-inner-shadow {
}
@media (max-width: 1199.98px) {
.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) {
.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 {

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>
<link rel="stylesheet" href="/css/font.css">
<component :is="layout">
<template #content>
<LoadingSpinner :isLoading="loadingStore.isLoading" />
<router-view></router-view>
</template>
</component>
@ -14,10 +12,7 @@ import { useRoute } from 'vue-router';
import NormalLayout from './layouts/NormalLayout.vue';
import NoLayout from './layouts/NoLayout.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 layout = computed(() => {

View File

@ -1,10 +1,9 @@
import axios from 'axios';
import router from '@/router';
import { useRoute } from 'vue-router';
import { useToastStore } from '@s/toastStore';
import { useLoadingStore } from '@s/loadingStore';
const $api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
baseURL: 'https://192.168.0.251:10325/api/',
timeout: 300000,
withCredentials: true,
});
@ -15,9 +14,6 @@ const $api = axios.create({
*/
$api.interceptors.request.use(
function (config) {
const loadingStore = useLoadingStore();
loadingStore.startLoading();
let contentType = 'application/json';
if (config.isFormData) contentType = 'multipart/form-data';
@ -28,8 +24,6 @@ $api.interceptors.request.use(
return config;
},
function (error) {
const loadingStore = useLoadingStore();
loadingStore.stopLoading();
// 요청 오류가 있는 작업 수행
return Promise.reject(error);
},
@ -38,80 +32,39 @@ $api.interceptors.request.use(
// 응답 인터셉터 추가하기
$api.interceptors.response.use(
function (response) {
const loadingStore = useLoadingStore();
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;
}
// 일반 성공 응답 처리
// 2xx 범위의 응답 처리
return response;
},
function (error) {
const loadingStore = useLoadingStore();
loadingStore.stopLoading();
const toastStore = useToastStore();
// 로그인 요청 별도 처리 (헤더에 isLogin이 true로 설정된 경우)
if (error.config && error.config.headers && error.config.headers.isLogin) {
// 로그인 페이지 오류 토스트 메시지 표시 X
return Promise.reject(error);
}
// 에러 응답에 커스텀 메시지가 포함되어 있다면 해당 메시지 사용
// if (error.response && error.response.data && error.response.data.message) {
// toastStore.onToast(error.response.data.message, 'e');
// } else if (error.response) {
const currentPage = error.config.headers['X-Page-Route'];
// 오류 응답 처리
if (error.response) {
// 기본 HTTP 에러 처리
switch (error.response.status) {
case 400:
toastStore.onToast('잘못된 요청입니다.', 'e');
router.push('/error/400');
break;
case 401:
toastStore.onToast('인증이 필요합니다.', 'e');
router.push('/login');
if (currentPage === '/login') {
toastStore.onToast('아이디 혹은 비밀번호가 틀렸습니다.', 'e');
} else {
toastStore.onToast('인증이 필요합니다.', 'e');
}
break;
case 403:
toastStore.onToast('접근 권한이 없습니다.', 'e');
break;
case 404:
toastStore.onToast('요청한 페이지를 찾을 수 없습니다.', 'e');
router.push('/error/404');
break;
case 500:
toastStore.onToast('서버 오류가 발생했습니다.', 'e');
router.push('/error/500');
break;
default:
toastStore.onToast('알 수 없는 오류가 발생했습니다.', 'e');
}
} else if (error.request) {
// 요청이 전송되었으나 응답을 받지 못한 경우
toastStore.onToast('서버와 통신할 수 없습니다.', 'e');
} else {
// 요청 설정 중에 오류가 발생한 경우
toastStore.onToast('요청 중 오류가 발생했습니다.', 'e');
}
@ -119,5 +72,4 @@ $api.interceptors.response.use(
},
);
export default $api;

View File

@ -1,221 +1,49 @@
/*
작성자 : 공현지
작성일 : 2025-01-17
수정자 : 박성용
수정일 : 2025-03-11
수정자 :
수정일 :
설명 : 공통 스크립트
*/
import Quill from 'quill';
/*
*템플릿 사용법 : $common.변수
*setup() 사용법 :
const { appContext } = getCurrentInstance();
const $common = appContext.config.globalProperties.$common;
or
import { inject } from 'vue';
const $common = inject('common');
*템플릿 사용법 : $common.변수
*setup() 사용법 :
const { appContext } = getCurrentInstance();
const $common = appContext.config.globalProperties.$common;
$common.변수
*/
const common = {
// JSON 문자열로 Delta 타입을 변환
contentToHtml(content) {
try {
if (content.startsWith('{') || content.startsWith('[')) {
// Delta 형식으로 변환
const delta = JSON.parse(content);
const quill = new Quill(document.createElement('div'));
quill.setContents(delta);
return quill.root.innerHTML; // HTML 반환
}
return content; // 이미 HTML일 경우 그대로 반환
} catch (error) {
console.error('콘텐츠 변환 오류:', error);
return content; // 오류 발생 시 원본 반환
}
},
// Delta 타입을 JSON 문자열로 변환
deltaAsJson(content) {
if (content && content.ops) {
return JSON.stringify(content.ops); // Delta 객체에서 ops 속성만 JSON 문자열로 변환
}
console.error('잘못된 Delta 객체:', content);
return null; // Delta 객체가 아니거나 ops가 없을 경우 null 반환
},
// JSON 문자열로 Delta 타입을 변환
contentToHtml(content) {
try {
if (content.startsWith('{') || content.startsWith('[')) {
// Delta 형식으로 변환
const delta = JSON.parse(content);
const quill = new Quill(document.createElement('div'));
quill.setContents(delta);
return quill.root.innerHTML; // HTML 반환
}
return content; // 이미 HTML일 경우 그대로 반환
} catch (error) {
console.error('콘텐츠 변환 오류:', error);
return content; // 오류 발생 시 원본 반환
}
},
// Delta 타입을 JSON 문자열로 변환
deltaAsJson(content) {
if (content && content.ops) {
return JSON.stringify(content.ops); // Delta 객체에서 ops 속성만 JSON 문자열로 변환
}
console.error('잘못된 Delta 객체:', content);
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 {
install(app) {
app.config.globalProperties.$common = common;
app.provide('common', common);
},
install(app) {
app.config.globalProperties.$common = common;
}
};

View File

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

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,244 +1,138 @@
<template>
<div>
<BoardProfile
:unknown="comment.author === '익명'"
:isCommentAuthor="isCommentAuthor"
:unknown="unknown"
:boardId="comment.boardId"
:profileName="displayName"
:profileName="comment.author"
:date="comment.createdAt"
:comment="comment"
:profileImg="comment.profileImg"
:showDetail="false"
:author="true"
:isLike="!isLike"
:isCommentPassword="isCommentPassword"
:isCommentProfile="true"
:is-edit-pushed="isEditPushed"
:is-delete-pushed="isDeletePushed"
@editClick="handleEditClick"
@deleteClick="$emit('deleteClick', comment)"
:isPassword="isPassword"
@editClick="editClick"
@deleteClick="deleteClick"
@submitPassword="submitPassword"
@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">
<template v-if="comment.isEditTextarea">
<textarea v-model="localEditedContent" class="form-control" maxLength="500"></textarea>
<span v-if="editCommentAlert" class="invalid-feedback d-block text-start">{{ editCommentAlert }}</span>
<template v-if="isEditTextarea">
<textarea v-model="editedContent" class="form-control"></textarea>
<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>
</template>
<template v-else>
<div class="m-0" style="white-space: pre-wrap">{{ comment.content }}</div>
<p class="m-0">{{ comment.content }}</p>
</template>
</div>
<!-- <p>현재 isDeleted : {{ isDeleted }}</p> -->
<!-- <template v-if="isDeleted">
<p class="m-0 text-muted">댓글이 삭제되었습니다.</p>
</template> -->
<PlusButton v-if="isPlusButton" @click="toggleComment" class="mt-6">
<i class="icon-base bx bx-plus beforeRotate" :class="{ rotate: isComment }"></i>
</PlusButton>
<PlusButton v-if="isPlusButton" @click="toggleComment" class="mt-6"/>
<BoardCommentArea v-if="isComment" @submitComment="submitComment"/>
<BoardCommentArea v-if="isComment" :unknown="unknown" @submitComment="submitComment" :commnetId="comment.commentId" />
<slot name="reply"></slot>
<!-- 대댓글 -->
<ul v-if="comment.children && comment.children.length" class="list-unstyled">
<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>
</template>
<script setup>
import { defineProps, defineEmits, ref, computed, watch, inject } from 'vue';
import BoardProfile from './BoardProfile.vue';
import BoardCommentArea from './BoardCommentArea.vue';
import PlusButton from '../button/PlusBtn.vue';
import SaveBtn from '../button/SaveBtn.vue';
import { defineProps, defineEmits, ref } from 'vue';
import BoardProfile from './BoardProfile.vue';
import BoardCommentArea from './BoardCommentArea.vue';
import PlusButton from '../button/PlusBtn.vue';
const props = defineProps({
comment: {
type: Object,
required: true,
},
unknown: {
type: Boolean,
default: false,
},
nickname: {
type: String,
default: '',
},
isCommentAuthor: {
type: Boolean,
default: false,
},
const props = defineProps({
comment: {
type: Object,
required: true,
},
unknown: {
type: Boolean,
default: true,
},
isPlusButton: {
type: Boolean,
default: true,
},
isLike: {
type: Boolean,
default: false,
},
isEditTextarea: {
type: Boolean,
default: false
},
isPassword: {
type: Boolean,
default: false,
},
});
isPlusButton: {
type: Boolean,
default: true,
},
isLike: {
type: Boolean,
default: false,
},
isEditTextarea: {
type: Boolean,
default: false,
},
isDeleted: {
type: Boolean,
default: false,
},
isCommentPassword: {
type: Boolean,
default: false,
},
passwordCommentAlert: {
type: String,
default: '',
},
currentPasswordCommentId: {
type: Number,
},
password: {
type: String,
},
// isEditPushed: {
// type: Boolean,
// required: false,
// },
// isDeletePushed: {
// type: Boolean,
// required: false,
// },
editCommentAlert: String,
// emits
const emit = defineEmits(['submitComment', 'updateReaction', 'toggleEdit', 'editClick']);
//
const isComment = ref(false);
const toggleComment = () => {
isComment.value = !isComment.value;
};
//
const submitComment = (newComment) => {
emit('submitComment', { parentId: props.comment.commentId, ...newComment });
isComment.value = false;
};
// ,
const handleUpdateReaction = (reactionData) => {
emit('updateReaction', {
boardId: props.comment.boardId,
commentId: props.comment.commentId,
...reactionData
});
};
const isEditPushed = ref(false);
const isDeletePushed = ref(false);
//
const editClick = (data) => {
emit('editClick', data);
};
const displayName = computed(() => {
return props.nickname ? props.nickname : props.comment.author;
});
//
const editedContent = ref(props.comment.content);
const submitEdit = () => {
emit('submitComment', { commentId: props.comment.commentId, content: editedContent.value });
emit('toggleEdit', props.comment.commentId, false); //
};
// emits
const emit = defineEmits([
'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 toggleComment = () => {
isComment.value = !isComment.value;
};
//
const submitComment = newComment => {
emit('submitComment', { parentId: props.comment.commentId, ...newComment, LOCBRDTYP: newComment.LOCBRDTYP });
isComment.value = false;
};
// ,
const handleUpdateReaction = reactionData => {
emit('updateReaction', {
boardId: props.comment.boardId,
commentId: props.comment.commentId || reactionData.commentId,
...reactionData,
});
};
//
const logPasswordAndEmit = () => {
emit('submitPassword', props.comment, props.password);
};
const handleInject = inject('isBtnPushed');
// ,
watch(
() => handleInject.value,
(newValue, oldValue) => {
if (newValue) {
if (newValue.target == props.comment.commentId) {
isEditPushed.value = newValue.isEditPushed;
isDeletePushed.value = newValue.isDeletePushed;
} else {
isEditPushed.value = false;
isDeletePushed.value = false;
}
}
},
);
watch(
() => props.comment.isEditTextarea,
newVal => {
if (newVal) {
localEditedContent.value = props.comment.content;
}
},
);
// text
watch(
() => localEditedContent.value,
newVal => {
if (JSON.stringify(isModifyContent.value) == JSON.stringify(newVal)) {
disabled.value = false;
return;
}
disabled.value = true;
emit('inputDetector');
},
);
// watch(() => props.comment.isDeleted, () => {
// console.log("BoardComment - isDeleted :", newVal);
// if (newVal) {
// localEditedContent.value = " ."; // UI
// props.comment.isEditTextarea = false;
// }
// });
//
const submitEdit = () => {
emit('submitEdit', props.comment, localEditedContent.value);
};
const handleEditClick = () => {
emit('editClick', props.comment);
};
</script>

View File

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

View File

@ -1,176 +1,67 @@
<template>
<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
:unknown="unknown"
:comment="comment"
:isCommentAuthor="comment.isCommentAuthor"
:isEditTextarea="comment.isEditTextarea"
:isDeleted="isDeleted"
:nickname="comment.nickname"
:isCommentPassword="isCommentPassword"
:passwordCommentAlert="passwordCommentAlert || ''"
:currentPasswordCommentId="currentPasswordCommentId"
:password="password"
:editCommentAlert="editCommentAlert[comment.commentId]"
:is-edit-pushed="comment.isEditPushed"
:is-delete-pushed="comment.isDeletePushed"
@editClick="handleEditClick"
@deleteClick="handleDeleteClick"
:isPassword="isPassword"
@editClick="editClick"
@deleteClick="deleteClick"
@submitPassword="submitPassword"
@submitComment="submitComment"
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent)"
@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>
</ul>
</template>
</BoardComment>
@updateReaction="(reactionData) => handleUpdateReaction(reactionData, comment.commentId)"
/>
<!-- @updateReaction="handleUpdateReaction" -->
</li>
</ul>
</template>
<script setup>
import { defineProps, defineEmits, watch } from 'vue';
import BoardComment from './BoardComment.vue';
import { defineProps, defineEmits } from 'vue';
import BoardComment from './BoardComment.vue'
const props = defineProps({
comments: {
type: Array,
required: true,
default: () => [],
},
unknown: {
type: Boolean,
default: true,
},
isCommentAuthor: {
type: Boolean,
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 props = defineProps({
comments: {
type: Array,
required: true,
default: () => []
},
unknown: {
type: Boolean,
default: true,
},
isPassword: {
type: Boolean,
default: false,
},
});
const emit = defineEmits([
'submitComment',
'updateReaction',
'editClick',
'deleteClick',
'submitPassword',
'clearPassword',
'submitEdit',
'update:password',
'inputDetector',
]);
const emit = defineEmits(['submitComment', 'updateReaction', 'editClick']);
const submitComment = replyData => {
emit('submitComment', replyData);
const submitComment = (replyData) => {
emit('submitComment', replyData);
};
const handleUpdateReaction = (reactionData, commentId) => {
// console.log('📢 BoardCommentList :', reactionData);
// console.log('📌 ID>>>>:', commentId);
const updatedReactionData = {
...reactionData,
commentId: commentId
};
const handleUpdateReaction = (reactionData, commentId, boardId) => {
const updatedReactionData = {
...reactionData,
commentId: commentId || reactionData.commentId,
boardId: boardId || reactionData.boardId,
};
// console.log('🚀 :', updatedReactionData);
emit('updateReaction', updatedReactionData);
};
emit('updateReaction', updatedReactionData);
}
const submitPassword = (comment, password) => {
emit('submitPassword', comment, password);
};
const editClick = (data) => {
emit('editClick', data);
};
const handleEditClick = comment => {
if (comment.parentId) {
emit('editClick', comment); //
} else {
emit('editClick', comment); //
}
};
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>

View File

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

View File

@ -1,89 +1,127 @@
<template v-if="isRecommend">
<button
class="btn btn-label-primary btn-icon me-1"
: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 class="btn btn-label-primary btn-icon" :class="{'clicked': likeClicked, 'big': bigBtn}" @click="handleLike">
<i class="fa-regular fa-thumbs-up"></i> <span class="num">{{ likeCount }}</span>
</button>
<button
class="btn btn-label-danger btn-icon"
: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 class="btn btn-label-danger btn-icon" :class="{'clicked': dislikeClicked, 'big': bigBtn}" @click="handleDislike">
<i class="fa-regular fa-thumbs-down"></i> <span class="num">{{ dislikeCount }}</span>
</button>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { ref, watch } from 'vue';
const props = defineProps({
comment: {
type: Object,
default: () => ({}),
},
likeClicked: {
type: Boolean,
default: false,
},
dislikeClicked: {
type: Boolean,
default: false,
},
bigBtn: {
type: Boolean,
default: false,
},
isRecommend: {
type: Boolean,
default: true,
},
boardId: {
type: Number,
required: true,
},
commentId: {
type: [Number, null],
default: null,
},
likeCount: {
type: Number,
default: 0,
},
dislikeCount: {
type: Number,
default: 0,
},
});
const props = defineProps({
likeClicked : {
type : Boolean,
default : false,
},
dislikeClicked : {
type : Boolean,
default : false,
},
bigBtn : {
type :Boolean,
default : false,
},
isRecommend: {
type:Boolean,
default:true,
},
boardId: {
type: Number,
required: true,
},
commentId: {
type: Number,
default: null,
},
likeCount: {
type: Number,
default: 0,
},
dislikeCount: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['updateReaction']);
const emit = defineEmits(['updateReaction']);
const likeClicked = ref(props.likeClicked);
const dislikeClicked = ref(props.dislikeClicked);
const likeCount = computed(() => props.comment?.likeCount ?? props.likeCount);
const dislikeCount = computed(() => props.comment?.dislikeCount ?? props.dislikeCount);
const likeClicked = ref(props.likeClicked);
const dislikeClicked = ref(props.dislikeClicked);
const likeCount = ref(props.likeCount);
const dislikeCount = ref(props.dislikeCount);
watch([() => props.likeClicked, () => props.dislikeClicked], ([likeNewval, dislikeNewval]) => {
likeClicked.value = likeNewval;
dislikeClicked.value = dislikeNewval;
});
// likeCount dislikeCount
watch(() => props.likeCount, (newVal) => {
likeCount.value = newVal;
});
const handleLike = () => {
const isLike = !likeClicked.value;
const isDislike = false;
watch(() => props.dislikeCount, (newVal) => {
dislikeCount.value = newVal;
});
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
likeClicked.value = isLike;
dislikeClicked.value = false;
};
const handleLike = () => {
const isLike = !likeClicked.value;
const isDislike = false;
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
likeClicked.value = isLike;
dislikeClicked.value = false;
};
const handleDislike = () => {
const isDislike = !dislikeClicked.value;
const isLike = false;
const handleDislike = () => {
const isDislike = !dislikeClicked.value;
const isLike = false;
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
dislikeClicked.value = isDislike;
likeClicked.value = false;
};
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
dislikeClicked.value = isDislike;
likeClicked.value = false;
};
</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>
<button class="btn btn-label-primary btn-icon" :class="{ active: props.isPushed }">
<i class="bx bx-trash"></i>
<button class="btn btn-label-primary btn-icon">
<i class='bx bx-trash' ></i>
</button>
</template>
<script setup>
const props = defineProps({
isPushed: {
type: Boolean,
required: false,
},
});
<script>
export default {
name: 'DeleteButton',
methods: {
},
};
</script>

View File

@ -1,38 +1,13 @@
<template>
<button class="btn btn-label-primary btn-icon" :class="{ active: props.isPushed }" @click="toggleText">
<i :class="buttonClass"></i>
<button class="btn btn-label-primary btn-icon">
<i class="bx bx-edit-alt"></i>
</button>
</template>
<script setup>
import { ref, watch, defineEmits, watchEffect } from 'vue';
const props = defineProps({
isToggleEnabled: {
type: Boolean,
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); //
<script>
export default {
name: 'EditButton',
methods: {
},
};
const resetButton = () => {
buttonClass.value = 'bx bx-edit-alt';
};
defineExpose({ resetButton });
</script>

View File

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

View File

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

View File

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

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>
<button class="btn btn-label-primary btn-icon float-end" @click="toggleText">
<i :class="buttonClass"></i>
<button class="btn btn-label-primary btn-icon float-end">
<i class="bx bx-edit"></i>
</button>
</template>
<script setup>
import { ref, defineProps, defineExpose, watch } from 'vue';
const props = defineProps({
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>
export default {
name: 'WriteButton',
methods: {
},
};
</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,65 +1,54 @@
<template>
<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">
<button
type="button"
class="btn"
:class="{
'btn-outline-primary': category.value.toString() !== selectedCategory?.toString(),
'btn-primary': category.value.toString() === selectedCategory?.toString()
<button
type="button"
class="btn"
:class="{
'btn-outline-primary': category.value !== selectedCategory,
'btn-primary': category.value === selectedCategory
}"
@click="selectCategory(category.value)"
>
{{ category.label }}
</button>
@click="selectCategory(category.value)"
>
{{ category.label }}
</button>
</li>
</ul>
</template>
<script setup>
import { defineProps, defineEmits, ref, watch } from 'vue';
import { defineProps, ref } from 'vue';
// lists prop
const props = defineProps({
lists: {
type: Array,
required: true,
},
showAll: {
type: Boolean,
required: false,
},
selectedCategory: {
type: [String,Number],
default: null,
required: false,
},
});
//
const selectedCategory = ref(props.selectedCategory);
const emit = defineEmits(['update:data']);
const selectedCategory = ref(null);
const emit = defineEmits();
const selectCategory = (cate) => {
selectedCategory.value = selectedCategory.value === cate ? null : cate;
emit('update:data', selectedCategory.value);
};
watch(() => props.selectedCategory, (newVal) => {
selectedCategory.value = newVal;
});
</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,276 +41,158 @@
<button class="ql-link">Link</button>
<button class="ql-image">Image</button>
<button class="ql-video">Video</button>
<button class="ql-blockquote">Blockquote</button>
<button class="ql-code-block">Code Block</button>
</div>
<!-- 에디터가 표시될 div -->
<div id="qEditor" ref="editor"></div>
<div ref="editor"></div>
<!-- Alert 메시지 표시 -->
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">내용을 확인해주세요.</div>
</div>
</template>
<script setup>
import Quill from 'quill';
import 'quill/dist/quill.snow.css';
import $api from '@api';
import { onMounted, ref, watch, defineEmits, defineProps } from 'vue';
import { useToastStore } from '@s/toastStore';
import Quill from 'quill';
import 'quill/dist/quill.snow.css';
import { onMounted, ref, watch, defineEmits, defineProps } from 'vue';
import $api from '@api';
const toastStore = useToastStore();
const props = defineProps({
isAlert: {
type: Boolean,
default: false,
},
initialData: {
type: [String, Object],
default: () => null,
const props = defineProps({
isAlert: {
type: Boolean,
default: false,
},
initialData: {
type: String,
default: () => null,
},
});
const editor = ref(null); // DOM
const font = ref('nanum-gothic'); //
const fontSize = ref('16px'); //
const emit = defineEmits(['update:data']);
onMounted(() => {
//
const Font = Quill.import('formats/font');
Font.whitelist = ['nanum-gothic', 'd2coding', 'consolas', 'serif', 'monospace'];
Quill.register(Font, true);
//
const Size = Quill.import('attributors/style/size');
Size.whitelist = ['12px', '14px', '16px', '18px', '24px', '32px', '48px'];
Quill.register(Size, true);
// Quill
const quillInstance = new Quill(editor.value, {
theme: 'snow',
placeholder: '내용을 입력해주세요...',
modules: {
toolbar: {
container: '#toolbar',
},
syntax: true,
},
});
const editor = ref(null); // DOM
const font = ref('nanum-gothic'); //
const fontSize = ref('16px'); //
const emit = defineEmits(['update:data', 'update:uploadedImgList', 'update:deleteImgIndexList']);
const uploadedImgList = ref([]); //
const initImageIndex = ref([]); //
const deleteImgIndexList = ref([]); //
//
quillInstance.format('font', font.value);
quillInstance.format('size', fontSize.value);
onMounted(() => {
//
const Font = Quill.import('formats/font');
Font.whitelist = ['nanum-gothic', 'd2coding', 'consolas', 'serif', 'monospace'];
Quill.register(Font, true);
//
quillInstance.on('text-change', () => {
const delta = quillInstance.getContents(); // Delta
emit('update:data', delta);
});
//
const Size = Quill.import('attributors/style/size');
Size.whitelist = ['12px', '14px', '16px', '18px', '24px', '32px', '48px'];
Quill.register(Size, true);
// Quill
const quillInstance = new Quill(editor.value, {
theme: 'snow',
placeholder: '내용을 입력해주세요...',
modules: {
toolbar: {
container: '#toolbar',
},
syntax: true,
},
});
//
//
watch([font, fontSize], () => {
quillInstance.format('font', font.value);
quillInstance.format('size', fontSize.value);
//
quillInstance.on('text-change', () => {
const delta = quillInstance.getContents(); // Delta
emit('update:data', delta);
});
//
watch([font, fontSize], () => {
quillInstance.format('font', font.value);
quillInstance.format('size', fontSize.value);
});
//
watch(uploadedImgList, () => {
emit('update:uploadedImgList', uploadedImgList.value);
});
// ()
watch(deleteImgIndexList, () => {
emit('update:deleteImgIndexList', deleteImgIndexList.value);
});
// , HTML
if (props.initialData) {
quillInstance.setContents(JSON.parse(props.initialData));
initCheckImageIndex();
}
//
quillInstance.getModule('toolbar').addHandler('video', () => {
const url = prompt('YouTube 영상 URL을 입력하세요:');
let src = '';
if (!url || url.trim() == '') return;
// youtube url
if (url.indexOf('watch?v=') !== -1) {
src = url.replace('watch?v=', 'embed/');
// youtu.be URL (ex : https://youtu.be/CfiojceAaeQ?si=G7eM56sdDjIEw-Tz)
} else if (url.indexOf('youtu.be/') !== -1) {
const videoId = url.split('youtu.be/')[1].split('?')[0];
src = `https://www.youtube.com/embed/${videoId}`;
// iframe
} else if (url.indexOf('<iframe') !== -1) {
// DOMParser embeded url
const parser = new DOMParser();
const doc = parser.parseFromString(url, 'text/html');
const iframeEL = doc.querySelector('iframe');
src = iframeEL.getAttribute('src');
} else {
toastStore.onToast('지원하는 영상 타입 아님', 'e');
return;
}
const index = quillInstance.getSelection().index;
quillInstance.insertEmbed(index, 'video', src);
quillInstance.setSelection(index + 1);
});
//
let imageUrls = new Set(); // URL
quillInstance.getModule('toolbar').addHandler('image', () => {
selectLocalImage(); //
});
//
quillInstance.on('text-change', (delta, oldDelta, source) => {
delta.ops.forEach(op => {
if (op.insert && typeof op.insert === 'object' && op.insert.image) {
const imageUrl = op.insert.image; // URL
imageUrls.add(imageUrl); // URL
} else if (op.delete) {
checkForDeletedImages(); //
}
});
checkDeletedImages();
emit('update:data', quillInstance.getContents());
});
//
async function selectLocalImage() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click(); //
input.onchange = () => {
const file = input.files[0];
if (file) {
const formData = new FormData();
formData.append('file', file);
// URL
uploadImageToServer(formData)
.then(data => {
const uploadImgIdx = data?.fileIndex; // DB
const serverImageUrl = data?.fileUrl; // url
// ( )
if (uploadImgIdx) {
uploadedImgList.value = [...uploadedImgList.value, uploadImgIdx];
initImageIndex.value = [...initImageIndex.value, uploadImgIdx];
}
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
//const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}`;
const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}?imgIndex=${uploadImgIdx}`; // index
const range = quillInstance.getSelection();
quillInstance.insertEmbed(range.index, 'image', fullImageUrl); //
imageUrls.add(fullImageUrl); // URL
})
.catch(e => {
toastStore.onToast('잠시후 다시 시도해주세요.', 'e');
});
}
};
}
//
async function uploadImageToServer(formData) {
try {
// Make the POST request to upload the image
const response = await $api.post('quilleditor/upload', formData, { isFormData: true });
// Check if the response contains the expected data
if (response.data && response.data.data) {
const imageUrl = response.data.data;
return imageUrl; // Return the image URL received from the server
} else {
throw new Error('Image URL not returned from server');
}
} catch (error) {
// Log detailed error information for debugging purposes
console.error('Image upload failed:', error);
// Handle specific error cases (e.g., network issues, authorization issues)
if (error.response) {
// If the error is from the server (e.g., 4xx or 5xx error)
console.error('Error response:', error.response.data);
toastStore.onToast('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', 'e');
} else if (error.request) {
// If no response is received from the server
console.error('No response received:', error.request);
toastStore.onToast('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', 'e');
} else {
// If the error is due to something else (e.g., invalid request setup)
console.error('Error message:', error.message);
toastStore.onToast('파일 업로드 중 문제가 발생했습니다. 다시 시도해주세요.', 'e');
}
// Throw the error so the caller knows something went wrong
throw error;
}
}
//
function checkForDeletedImages() {
const editorImages = document.querySelectorAll('#qEditor img');
const currentImages = new Set(Array.from(editorImages).map(img => img.src)); //
imageUrls.forEach(url => {
if (!currentImages.has(url)) {
imageUrls.delete(url);
}
});
}
//
function initCheckImageIndex() {
const editorImages = document.querySelectorAll('#qEditor img');
const currentImages = new Set(Array.from(editorImages).map(img => img.src)); //
currentImages.forEach(url => {
const index = getImgIndex(url);
if (index) {
initImageIndex.value.push(Number(index));
}
});
}
// index
function getImgIndex(url) {
const params = new URLSearchParams(url.split('?')[1]);
return params.get('imgIndex');
}
//
function checkDeletedImages() {
const editorImages = document.querySelectorAll('#qEditor img');
const currentImages = new Set(Array.from(editorImages).map(img => img.src));
// init
const tempDeleteImgIndex = [...initImageIndex.value];
currentImages.forEach(url => {
const imgIndex = getImgIndex(url);
if (imgIndex) {
const index = tempDeleteImgIndex.indexOf(imgIndex);
tempDeleteImgIndex.splice(index, 1);
}
});
deleteImgIndexList.value = tempDeleteImgIndex;
}
});
// , HTML
if (props.initialData) {
quillInstance.setContents(JSON.parse(props.initialData));
}
//
let imageUrls = new Set(); // URL
quillInstance.getModule('toolbar').addHandler('image', () => {
selectLocalImage(); //
});
//
quillInstance.on('text-change', (delta, oldDelta, source) => {
emit('update:data', quillInstance.getContents());
delta.ops.forEach(op => {
if (op.insert && typeof op.insert === 'object' && op.insert.image) {
const imageUrl = op.insert.image; // URL
imageUrls.add(imageUrl); // URL
} else if (op.delete) {
checkForDeletedImages(); //
}
});
});
//
async function selectLocalImage() {
const input = document.createElement('input');
input.setAttribute('type', 'file');
input.setAttribute('accept', 'image/*');
input.click(); //
input.onchange = () => {
const file = input.files[0];
if (file) {
const formData = new FormData();
formData.append('file', file);
// URL
uploadImageToServer(formData).then(serverImageUrl => {
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}`;
const range = quillInstance.getSelection();
quillInstance.insertEmbed(range.index, 'image', fullImageUrl); //
imageUrls.add(fullImageUrl); // URL
}).catch(e => {
toastStore.onToast('잠시후 다시 시도해주세요.', 'e');
});
}
};
}
//
async function uploadImageToServer(formData) {
try {
const response = await $api.post('quilleditor/upload', formData, { isFormData: true });
const imageUrl = response.data.data;
return imageUrl; // URL
} catch (error) {
toastStore.onToast('잠시후 다시 시도해주세요.', 'e');
throw error;
}
}
//
function checkForDeletedImages() {
const editorImages = document.querySelectorAll('#editor img');
const currentImages = new Set(Array.from(editorImages).map(img => img.src)); //
imageUrls.forEach(url => {
if (!currentImages.has(url)) {
imageUrls.delete(url);
}
});
}
});
</script>
<style>
@import 'quill/dist/quill.snow.css';
.ql-editor {
min-height: 300px;
font-family: 'Nanum Gothic', sans-serif;
}
</style>

View File

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

View File

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

View File

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

View File

@ -2,44 +2,21 @@
<div class="mb-2" :class="isRow ? 'row' : ''">
<label :for="name" class="col-md-2 col-form-label" :class="isLabel ? 'd-block' : 'd-none'">
{{ title }}
<span v-if="isEssential" class="link-danger">*</span>
<span :class="isEssential ? 'link-danger' : 'none'">*</span>
</label>
<div :class="isRow ? 'col-md-10' : 'col-md-12'">
<div class="d-flex gap-2 align-items-center">
<select v-if="isColor && (!data || data.length === 0)" class="form-select" disabled>
<option>사용가능한 컬러가 없습니다</option>
</select>
<!-- 데이터가 있는 경우 원래 select 표시 -->
<select v-else class="form-select" :id="name" v-model="selectData" :disabled="disabled" :style="isColor ? { color: selected } : {}" @blur="$emit('blur')">
<option v-for="(item, i) in data" :key="i" :value="isCommon ? item.value : i" :style="isColor ? { color: item.label } : {}">
{{ isCommon ? item.label : item }}
</option>
</select>
<div v-if="isBtn" class="ms-2">
<slot name="append"></slot>
</div>
<div v-if="isColor && selected"
class="w-px-40 h-px-30"
:style="{backgroundColor: selected}">
</div>
<img v-if="isMbti && selected"
role="img"
class="w-px-30 h-px-40"
:src="`/img/mbti/${selected.toLowerCase()}.png`"
alt="MBTI image"/>
</div>
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div>
<select class="form-select" :id="name" v-model="selectData" :disabled="disabled">
<option v-for="(item, i) in data" :key="i" :value="isCommon ? item.value : i">
{{ isCommon ? item.label : item }}
</option>
</select>
</div>
<div v-if="isAlert" class="invalid-feedback">{{ title }} 확인해주세요.</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue';
import { ref, watch } from 'vue';
const props = defineProps({
title: {
@ -82,11 +59,6 @@ const props = defineProps({
default: true,
required: false,
},
isBtn: {
type: Boolean,
default: false,
required: false,
},
isCommon: {
type: Boolean,
default: false,
@ -95,20 +67,10 @@ const props = defineProps({
disabled: {
type: Boolean,
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);
// props.value watch
@ -123,10 +85,6 @@ watch(() => props.data, (newData) => {
if (props.value === '0') {
selectData.value = newData[0].value;
emit('update:data', selectData.value);
if (props.isColor) {
emit('blur');
}
}
}
}, { immediate: true });
@ -135,14 +93,4 @@ watch(() => props.data, (newData) => {
watch(selectData, (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>
</label>
<div class="col-md-12">
<div v-if="useInputGroup" class="input-group mb-1">
<div v-if="useInputGroup" class="input-group mb-3">
<input
:id="name"
class="form-control"
@ -14,7 +14,7 @@
:value="computedValue"
:disabled="disabled"
:maxLength="maxlength"
:placeholder="placeholder ? placeholder : title"
:placeholder="title"
@blur="$emit('blur')"
/>
<span class="input-group-text">@ localhost.co.kr</span>
@ -29,10 +29,8 @@
:value="computedValue"
:disabled="disabled"
:maxLength="maxlength"
:placeholder="placeholder ? placeholder : title"
:placeholder="title"
@blur="$emit('blur')"
@click="handleDateClick"
ref="inputElement"
/>
</div>
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div>
@ -41,7 +39,7 @@
</template>
<script setup>
import { inject, computed, ref } from 'vue';
import { inject, computed } from 'vue';
const props = defineProps({
title: {
@ -89,14 +87,9 @@
default: false,
required: false,
},
placeholder: {
type: String,
default: ''
},
});
const emits = defineEmits(['update:data', 'update:alert', 'blur']);
const inputElement = ref(null);
// dayjs
const dayjs = inject('dayjs');
@ -125,12 +118,4 @@
emits('update:alert', false);
}
};
// date input
const handleDateClick = (event) => {
if (props.type === 'date' && inputElement.value) {
// : UI
inputElement.value.showPicker();
}
};
</script>

View File

@ -1,213 +1,64 @@
<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="card-body">
<!-- 제목 -->
<div class="d-flex justify-content-between">
<h5 class="card-title fw-bold">
{{ title }}
</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>
<h5 class="card-title">
{{ title }}
</h5>
<!-- 날짜 -->
<div class="row align-items-center pb-2">
<div class="col-3 col-md-2 d-flex align-items-center">
<i class="bx bx-calendar"></i>
<div class="ms-2">날짜</div>
</div>
<div class="col-9 col-md-10">
{{ strdate }} ~ {{ enddate }}
</div>
<div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<i class="bx bx-calendar"></i>
<div class="ms-2">날짜</div>
<div class="ms-12">{{ strdate }} ~ {{ enddate }}</div>
</div>
<!-- 참여자 -->
<div class="row align-items-center pb-2">
<div class="col-3 col-md-2 d-flex align-items-center">
<i class="bx bxs-user"></i>
<div class="ms-2">참여자</div>
</div>
<div class="col-9 col-md-10">
<UserList ref="userListRef" :projctSeq="projctSeq" :showOnlyActive="true" class="mb-0" />
</div>
<div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<i class="bx bxs-user"></i>
<div class="ms-2">참여자</div>
<UserList :projctSeq="projctSeq" class="ms-8 mb-0" />
</div>
<!-- 설명 -->
<div class="row align-items-center pb-2">
<div class="col-3 col-md-2 d-flex align-items-center">
<i class="bx bx-detail"></i>
<div class="ms-2">설명</div>
</div>
<div class="col-9 col-md-10">
{{ description || '-' }}
</div>
<div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<i class="bx bx-detail"></i>
<div class="ms-2">설명</div>
<div class="ms-12">{{ description }}</div>
</div>
<!-- 주소 -->
<div class="row align-items-center pb-2">
<div class="col-3 col-md-2 d-flex align-items-center">
<MapPopover
:address="address"
:is-visible="isMapVisible"
@update-visible="updatePopover"
>
<template #trigger>
<div class="d-flex align-items-center cursor-pointer">
<i class="bx bxs-map"></i>
<div class="ms-2">주소</div>
</div>
</template>
</MapPopover>
</div>
<div class="col-9 col-md-10 d-flex justify-content-between align-items-center">
<div>{{ address }} {{ addressdtail }}</div>
<button type="button" class="btn text-white" :style="`background-color: ${projctColor} !important;`" @click.stop="openModal">
<i class='bx bx-child'></i>
</button>
</div>
<div class="d-flex flex-column flex-sm-row align-items-center pb-2">
<i class="bx bxs-map"></i>
<div class="ms-2">주소</div>
<div class="ms-12">{{ address }}</div>
<button type="button" class="btn ms-auto text-white" :style="`background-color: ${projctCol} !important;`" @click.stop="openModal">log</button>
</div>
</div>
</div>
</div>
<!-- 로그 모달 -->
<CenterModal :display="isModalOpen" @close="closeModal">
<template #title> 등록·수정자 </template>
<CenterModal :display="isModalOpen" @close="closeModal" >
<template #title> Log </template>
<template #body>
<div v-if="logData.length > 0">
<div
v-for="(log, index) in logData"
:key="index"
class="ms-4 mt-2 border p-3"
>
<p class="mb-1">{{ log.logDate }}</p>
<strong>{{ log.logMessage }}</strong>
</div>
<div class="border border-3 rounded p-5 ms-4 mt-2" v-if="logData">
<p class="mb-1">{{ logData.createDate }}</p>
<strong class="">[{{ logData.creator }}] 프로젝트 등록</strong>
</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>
</template>
<template #footer>
<BackBtn @click="closeModal" />
</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" />
<button type="button" class="btn btn-secondary" @click="closeModal">닫기</button>
</template>
</CenterModal>
</template>
<script setup>
import { defineProps, onMounted, ref, computed, watch } from 'vue';
import { defineProps, ref } from 'vue';
import UserList from '@c/user/UserList.vue';
import CenterModal from '@c/modal/CenterModal.vue';
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
const props = defineProps({
@ -227,353 +78,43 @@ const props = defineProps({
description: {
type: String,
required: false,
default: "",
},
address: {
type: String,
required: true,
},
addressdtail: {
type: String,
required: true,
},
addressZip: {
type: String,
required: true,
},
projctSeq: {
type: Number,
required: false
},
projctCol: {
type: Number,
required: false
},
projctColor: {
type: String,
required: false
},
projctCreatorId: {
type: Number,
required: false
},
resetUserSelection: {
type: Boolean,
default: false
},
searchParams: {
type: Object,
default: () => ({ text: '', year: null })
}
});
// Emit
const emit = defineEmits(['update']);
defineEmits(['click']);
//
const isModalOpen = ref(false);
const logData = ref([]);
const logData = ref(null);
const isMapVisible = ref(null);
//
const isEditModalOpen = ref(false);
const originalColor = ref('');
const nameAlert = ref(false);
const startDayAlert = ref(false);
const user = ref(null);
const editUserListRef = ref(null);
const userListRef = ref(null);
const selectedUsers = ref({
activeUsers: [],
disabledUsers: []
});
const startDateInput = ref(null);
const endDateInput = ref(null);
// DOM
let startInputElement = null;
let endInputElement = null;
const openStartDatePicker = () => {
if (startInputElement) {
startInputElement.showPicker();
const fetchLogData = async () => {
try {
const response = await $api.get(`project/log/${props.projctSeq}`);
logData.value = response.data.data.length > 0 ? response.data.data[0] : {};
} catch (error) {
console.error('로그 정보 조회 실패:', error);
}
};
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 () => {
await getLogData();
await fetchLogData();
isModalOpen.value = true;
};
//
const closeModal = () => {
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>

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>
<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 class="modal-content">
<div class="modal-header">
<h5 class="modal-title m-auto fw-bold" id="modalCenterTitle">
<slot name="title">Modal Title</slot>
</h5>
<button type="button" class="btn-close" @click="closeModal" aria-label="Close"></button>
</div>
<div class="modal-body">
<slot name="body">Modal body</slot>
@ -20,31 +21,26 @@
</template>
<script setup>
const prop = defineProps({
display : {
type: Boolean,
default: false,
required: true,
},
create: {
type: Boolean,
default: false,
}
});
const emit = defineEmits(['close' , 'reset']);
const emit = defineEmits(['close']);
const closeModal = () => {
if (prop.create) {
emit('reset');
}
emit('close', false);
emit('close' , false);
};
</script>
<style>
.modal-back {
background: rgba(0, 0, 0, 0.5);
}
</style>

View File

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

View File

@ -1,125 +1,204 @@
<template>
<div v-if="isOpen" class="vac-modal-dialog" @click.self="closeModal">
<div class="vac-modal-content">
<div class="vac-modal-header">
<h5 class="vac-grant-modal-title">To. {{ targetUser.MEMBERNAM }} 🎁</h5>
<button class="close-btn" @click="closeModal"></button>
</div>
<div class="vac-modal-body">
<p class="vac-modal-text">선물할 연차 개수를 선택해 주세요.</p>
<div class="count-container">
<button @click="decreaseCount" :disabled="grantCount < 2" class="count-btn">-</button>
<span class="count-value">{{ grantCount }}</span>
<button @click="increaseCount" :disabled="grantCount >= availableQuota" class="count-btn">+</button>
</div>
<div class="custom-button-container">
<button class="custom-button" @click="saveVacationGrant" :disabled="grantCount === 0 || isGiftButtonDisabled">
<i class="bx bx-gift"></i>
</button>
</div>
</div>
</div>
</div>
</template>
<div v-if="isOpen" class="modal-dialog" @click.self="closeModal">
<div class="modal-content">
<h5 class="modal-title">To. {{ targetUser.MEMBERNAM }} 🎁</h5>
<button class="close-btn" @click="closeModal"></button>
<script setup>
import { ref, defineProps, defineEmits, watch, onMounted, computed } from "vue";
import axios from "@api";
import { useToastStore } from '@s/toastStore';
import { useUserInfoStore } from "@s/useUserInfoStore";
const userStore = useUserInfoStore();
const toastStore = useToastStore();
const props = defineProps({
<div class="modal-body">
<p>해당 직원에게 부여할 연차 개수를 선택하세요. (남은 개수: {{ availableQuota }})</p>
<div class="vacation-control">
<button @click="decreaseCount" :disabled="grantCount < 2" class="count-btn">-</button>
<span class="grant-count">{{ grantCount }}</span>
<button @click="increaseCount" :disabled="grantCount >= availableQuota" class="count-btn">+</button>
</div>
<button class="gift-btn" @click="saveVacationGrant" :disabled="grantCount === 0">
<i class="bx bx-gift"></i>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, watch, onMounted } from "vue";
import axios from "@api";
const props = defineProps({
isOpen: Boolean,
targetUser: Object,
remainingVacationData: Object,
});
});
const emit = defineEmits(["close", "updateVacation"]);
const grantCount = ref(0);
const maxQuota = 2;
const sentCount = ref(0);
const availableQuota = ref(2);
const emit = defineEmits(["close", "updateVacation"]);
const grantCount = ref(0);
const maxQuota = 2;
const sentCount = ref(0);
const availableQuota = ref(2);
const myUserId = computed(() => userStore.user.id);
const myRemainingQuota = computed(() => {
return props.remainingVacationData?.[myUserId.value] ?? 0;
});
const isGiftButtonDisabled = computed(() => {
return myRemainingQuota.value < 0;
});
//
const fetchSentVacationCount = async () => {
const fetchSentVacationCount = async () => {
try {
const payload = { receiverId: props.targetUser.MEMBERSEQ };
const response = await axios.get("vacation/sent", { params: payload });
sentCount.value = response.data.data[0]?.count || 0;
availableQuota.value = Math.max(maxQuota - sentCount.value, 0);
grantCount.value = availableQuota.value;
const payload = { receiverId: props.targetUser.MEMBERSEQ };
const response = await axios.get("vacation/sent", { params: payload });
sentCount.value = response.data.data[0].count || 0;
availableQuota.value = Math.max(maxQuota - sentCount.value, 0);
grantCount.value = availableQuota.value; //
} catch (error) {
availableQuota.value = maxQuota;
grantCount.value = maxQuota;
console.error("🚨 연차 전송 기록 조회 실패:", error);
availableQuota.value = maxQuota;
grantCount.value = maxQuota; //
}
};
};
//
const increaseCount = () => {
const increaseCount = () => {
if (grantCount.value < availableQuota.value) {
grantCount.value++;
grantCount.value++;
}
};
//
const decreaseCount = () => {
};
const decreaseCount = () => {
if (grantCount.value > 0) {
grantCount.value--;
grantCount.value--;
}
};
};
//
const saveVacationGrant = async () => {
const saveVacationGrant = async () => {
try {
const payload = [{
date: new Date().toISOString().split("T")[0],
type: "700103",
receiverId: props.targetUser.MEMBERSEQ,
count: grantCount.value,
}];
const response = await axios.post("vacation", payload);
if (response.data?.status === "OK") {
toastStore.onToast('연차가 선물되었습니다.', 's');
await fetchSentVacationCount();
emit("updateVacation");
closeModal();
} else {
toastStore.onToast(' 연차 선물 중 오류가 발생했습니다.', 'e');
}
const payload = [
{
date: new Date().toISOString().split("T")[0],
type: "700103",
receiverId: props.targetUser.MEMBERSEQ,
count: grantCount.value,
},
];
console.log(props.targetUser)
console.log(payload)
const response = await axios.post("vacation", payload);
console.log(response)
if (response.data && response.data.status === "OK") {
alert("✅ 연차가 부여되었습니다.");
await fetchSentVacationCount();
emit("updateVacation");
closeModal();
} else {
alert("🚨 연차 추가 중 오류가 발생했습니다.");
}
} catch (error) {
toastStore.onToast(' 연차 선물 실패!!.', 'e');
console.error("🚨 연차 추가 실패:", error);
alert("연차 추가에 실패했습니다.");
}
};
};
//
const closeModal = () => {
const closeModal = () => {
emit("close");
};
};
watch(() => props.isOpen, async (newVal) => {
if (newVal && props.targetUser?.MEMBERSEQ) {
watch(
() => props.isOpen,
async (newVal) => {
if (newVal && props.targetUser && props.targetUser.MEMBERSEQ) {
console.log("🟢 모달이 열렸습니다. 데이터를 로드합니다.");
await fetchSentVacationCount();
}
}
});
watch(() => props.targetUser, async (newUser) => {
if (newUser?.MEMBERSEQ) {
await fetchSentVacationCount();
}
}, { deep: true });
);
onMounted(async () => {
if (props.isOpen && props.targetUser?.MEMBERSEQ) {
watch(
() => props.targetUser,
async (newUser) => {
if (newUser && newUser.MEMBERSEQ) {
await fetchSentVacationCount();
}
});
</script>
}
},
{ deep: true }
);
<style scoped>
</style>
onMounted(async () => {
if (props.isOpen && props.targetUser && props.targetUser.MEMBERSEQ) {
await fetchSentVacationCount();
}
});
</script>
<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>

View File

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

@ -1,5 +1,5 @@
<template>
<SearchBar @update:data="search" />
<SearchBar @update:data="search"/>
<div class="d-flex align-items-center">
<CategoryBtn :lists="yearCategory" @update:data="selectedCategory = $event" />
<WriteBtn class="mt-2 ms-auto" @click="openCreateModal" />
@ -7,372 +7,360 @@
<!-- 프로젝트 목록 -->
<div class="mt-4">
<div v-if="projectStore.projectList.length === 0" class="text-center">
<p class="text-muted mt-4">등록된 프로젝트가 없습니다.</p>
<div v-if="projectList.length === 0" class="text-center">
<p class="text-muted mt-4">게시물이 없습니다.</p>
</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
:title="post.PROJCTNAM"
:description="post.PROJCTDES ?? ''"
:description="post.PROJCTDES"
:strdate="post.PROJCTSTR"
:enddate="post.PROJCTEND"
:address="post.PROJCTARR"
:addressdtail="post.PROJCTDTL"
:addressZip="post.PROJCTZIP"
:address="post.PROJCTARR + ' ' + post.PROJCTDTL"
:projctSeq="post.PROJCTSEQ"
:projctCol="post.PROJCTCOL"
:projctColor="post.projctcolor"
:projctCreatorId="post.PROJCTCMB"
:resetUserSelection="resetUserSelection"
:searchParams="{ text: searchText, year: selectedYear }"
@update="handleProjectUpdate"
:projctCol="post.projctcolor"
/>
</div>
</div>
<!-- 등록 모달 -->
<form @reset.prevent="formReset">
<CenterModal :display="isCreateModalOpen" @close="closeCreateModal" :create="true" @reset="formReset">
<template #title> 프로젝트 등록 </template>
<template #body>
<FormInput
title="프로젝트명"
name="name"
:is-essential="true"
:is-alert="nameAlert"
:modelValue="name"
@update:alert="nameAlert = $event"
@update:modelValue="name = $event"
/>
<CenterModal :display="isCreateModalOpen" @close="closeCreateModal">
<template #title> 프로젝트 등록 </template>
<template #body>
<FormInput
title="이름"
name="name"
:is-essential="true"
:is-alert="nameAlert"
@update:modelValue="name = $event"
/>
<FormSelect
title="컬러"
name="color"
:is-essential="true"
:is-label="true"
:is-common="true"
:is-color="true"
:value="color"
:data="colorList"
@update:data="color = $event"
:is-alert="colorAlert"
/>
<FormSelect
title="컬러"
name="color"
:is-essential="true"
:is-label="true"
:is-common="true"
:data="colorList"
@update:data="color = $event"
/>
<div class="mb-2 row">
<label class="col-md-2 col-form-label">
참여자
</label>
<div class="col-md-10">
<UserList
ref="userListRef"
@user-list-update="handleUserListUpdate"
class="m-0"
/>
</div>
</div>
<FormInput
title="시작 일"
type="date"
name="startDay"
v-model="startDay"
:is-essential="true"
/>
<div @click="openStartDatePicker">
<FormInput
title="시작 일"
name="startDay"
:type="'date'"
:is-alert="startDayAlert"
:is-essential="true"
:modelValue="startDay"
v-model="startDay"
ref="startDateInput"
/>
</div>
<div @click="openEndDatePicker">
<FormInput
title="종료 일"
name="endDay"
:type="'date'"
:modelValue="endDay"
:min="startDay"
@update:modelValue="endDay = $event"
ref="endDateInput"
/>
</div>
<FormInput
title="설명"
name="description"
:modelValue="description"
@update:modelValue="description = $event"
/>
<FormInput
title="종료 일"
name="endDay"
:type="'date'"
@update:modelValue="endDay = $event"
/>
<ArrInput
title="주소"
name="address"
:isEssential="true"
:is-row="true"
:is-alert="addressAlert"
:modelValue="addressData"
@update:data="handleAddressUpdate"
@update:alert="addressAlert = $event"
/>
</template>
<template #footer>
<BackButton type="reset" @click="closeCreateModal" />
<SaveButton @click="handleCreate" />
</template>
</CenterModal>
</form>
<FormInput
title="설명"
name="description"
@update:modelValue="description = $event"
/>
<ArrInput
title="주소"
name="address"
:isEssential="true"
:is-row="true"
:is-alert="addressAlert"
@update:data="handleAddressUpdate"
@update:alert="addressAlert = $event"
/>
</template>
<template #footer>
<BackButton @click="closeCreateModal" />
<SaveButton @click="handleCreate" />
</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"
/>
<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>
<script setup>
import { computed, ref, watch, onMounted, inject, nextTick } from 'vue';
import SearchBar from '@c/search/SearchBar.vue';
import ProjectCard from '@c/list/ProjectCard.vue';
import CategoryBtn from '@/components/category/CategoryBtn.vue';
import WriteBtn from '@c/button/WriteBtn.vue';
import CenterModal from '@c/modal/CenterModal.vue';
import FormSelect from '@c/input/FormSelect.vue';
import FormInput from '@c/input/FormInput.vue';
import ArrInput from '@c/input/ArrInput.vue';
import UserList from '@c/user/UserList.vue';
import commonApi, { refreshColorList } from '@/common/commonApi';
import { useToastStore } from '@s/toastStore';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useProjectStore } from '@/stores/useProjectStore';
import $api from '@api';
import SaveButton from '@c/button/SaveBtn.vue';
import BackButton from '@c/button/BackBtn.vue';
import { computed, inject, ref, watch, onMounted } from 'vue';
import SearchBar from '@c/search/SearchBar.vue';
import ProjectCard from '@c/list/ProjectCard.vue';
import CategoryBtn from '@c/category/CategoryBtn.vue';
import WriteBtn from '@c/button/WriteBtn.vue';
import CenterModal from '@c/modal/CenterModal.vue';
import FormSelect from '@c/input/FormSelect.vue';
import FormInput from '@c/input/FormInput.vue';
import ArrInput from '@c/input/ArrInput.vue';
import commonApi from '@/common/commonApi';
import { useToastStore } from '@s/toastStore';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import $api from '@api';
import SaveButton from '@c/button/SaveBtn.vue';
import BackButton from '@c/button/BackBtn.vue'
const toastStore = useToastStore();
const userStore = useUserInfoStore();
const projectStore = useProjectStore();
const dayjs = inject('dayjs');
const today = dayjs().format('YYYY-MM-DD');
const toastStore = useToastStore();
const userStore = useUserInfoStore();
//
const user = ref(null);
const selectedCategory = ref(null);
const searchText = ref('');
//
const user = ref(null);
const projectList = ref([]);
const filteredProjects = ref([]);
const selectedCategory = ref(null);
const searchText = ref('');
const userListRef = ref(null);
const resetUserSelection = ref(false);
//
const isCreateModalOpen = ref(false);
const name = ref('');
const color = ref('');
const address = ref('');
const detailAddress = ref('');
const postcode = ref('');
const startDay = ref(today);
const endDay = ref('');
const description = ref('');
const nameAlert = ref(false);
const addressAlert = ref(false);
// dayjs
const dayjs = inject('dayjs');
//
const isEditModalOpen = ref(false);
const originalColor = ref('');
const selectedProject = ref({
PROJCTSEQ: '',
PROJCTNAM: '',
PROJCTSTR: '',
PROJCTEND: '',
PROJCTZIP: '',
PROJCTARR: '',
PROJCTDTL: '',
PROJCTDES: '',
PROJCTCOL: '',
projctcolor: '',
});
// YYYY-MM-DD
const today = dayjs().format('YYYY-MM-DD');
// API
const { yearCategory, colorList } = commonApi({
loadColor: true,
colorType: 'YNP',
loadYearCategory: true,
});
//
const isCreateModalOpen = ref(false);
const name = ref('');
const color = ref('0');
const startDay = ref(today);
const endDay = ref('');
const description = ref('');
const nameAlert = ref(false);
const colorAlert = ref(false);
const addressAlert = ref(false);
const startDayAlert = ref(false);
const startDateInput = ref(null);
const endDateInput = ref(null);
// DOM
let startInputElement = null;
let endInputElement = null;
const openStartDatePicker = () => {
if (startInputElement) {
startInputElement.showPicker();
}
};
const openEndDatePicker = () => {
if (endInputElement) {
endInputElement.showPicker();
}
};
const addressData = ref({
postcode: '',
address: '',
detailAddress: ''
//
const getProjectList = async () => {
const res = await $api.get('project/select', {
params: {
searchKeyword : searchText.value,
category : selectedYear.value,
},
});
projectList.value = res.data.data.projectList;
};
//
const selectedUsers = ref({
activeUsers: [],
disabledUsers: []
});
//
const search = async (searchKeyword) => {
searchText.value = searchKeyword.trim();
await getProjectList();
};
// UserList
const handleUserListUpdate = (userLists) => {
selectedUsers.value = userLists;
};
// API
const { yearCategory, colorList } = commonApi({
loadColor: true,
colorType: 'YNP',
loadYearCategory: true,
});
//
const search = async searchKeyword => {
searchText.value = searchKeyword.trim();
await getProjectList();
};
const selectedYear = computed(() => {
if (!selectedCategory.value || selectedCategory.value === 900101) {
return null;
}
// category label
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 () => {
await getProjectList();
});
//
const openCreateModal = async () => {
const updatedColors = await refreshColorList('YNP');
if (updatedColors && updatedColors.length > 0) {
color.value = updatedColors[0].value;
}
isCreateModalOpen.value = true;
};
const closeCreateModal = () => {
isCreateModalOpen.value = false;
resetUserSelection.value = !resetUserSelection.value;
};
const formReset = () => {
name.value = '';
if (colorList.value && colorList.value.length > 0) {
color.value = colorList.value[0].value;
}
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 selectedYear = computed(() => {
if (!selectedCategory.value || selectedCategory.value === 900101) {
return null;
}
// category label
return yearCategory.value.find(item => item.value === selectedCategory.value)?.label || null;
});
// ::
const handleAddressUpdate = (data) => {
addressData.value = data;
};
//
watch(selectedCategory, async () => {
await getProjectList();
});
//
watch(startDay, (newStartDate) => {
if (new Date(newStartDate) > new Date(endDay.value)) {
endDay.value = '';
}
});
//
const openCreateModal = () => {
isCreateModalOpen.value = true;
};
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 closeCreateModal = () => {
isCreateModalOpen.value = false;
};
//
const updatedColors = await refreshColorList('YNP');
// ::
const handleAddressUpdate = addressData => {
address.value = addressData.address;
detailAddress.value = addressData.detailAddress;
postcode.value = addressData.postcode;
};
// ()
if (updatedColors && updatedColors.length > 0) {
color.value = updatedColors[0].value;
}
};
const handleCreate = async () => {
nameAlert.value = name.value.trim() === '';
addressAlert.value = address.value.trim() === '';
//
const handleCreate = async () => {
nameAlert.value = name.value.trim() === '';
startDayAlert.value = startDay.value.trim() === '';
addressAlert.value = addressData.value.address.trim() === '';
if (nameAlert.value || addressAlert.value) {
return;
}
if (!colorList.value || colorList.value.length === 0) {
colorAlert.value = true;
}
if (nameAlert.value || startDayAlert.value || addressAlert.value || colorAlert.value) {
return;
}
//
const disabledMemberSeqs = selectedUsers.value.disabledUsers.map(user => user.MEMBERSEQ);
const response = await $api.post('project/insert', {
projctNam: name.value,
projctCol: color.value,
projctStr: startDay.value,
projctEnd: endDay.value || null,
projctDes: description.value || null,
projctArr: addressData.value.address,
projctDtl: addressData.value.detailAddress,
projctZip: addressData.value.postcode,
projctCmb: user.value.id,
disabledMembers: disabledMemberSeqs
});
if (response.status === 200) {
$api.post('project/insert', {
projctNam: name.value,
projctCol: color.value,
projctStr: startDay.value,
projctEnd: endDay.value || null,
projctDes: description.value || null,
projctArr: address.value,
projctDtl: detailAddress.value,
projctZip: postcode.value,
projctCmb: user.value.name,
})
.then(res => {
if (res.status === 200) {
toastStore.onToast('프로젝트가 등록되었습니다.', 's');
colorList.value = colorList.value.filter(c => c.value !== color.value);
formReset();
await getProjectList();
await projectStore.getMemberProjects();
closeCreateModal();
resetUserSelection.value = !resetUserSelection.value;
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];
});
//
const hasChanges = computed(() => {
const original = projectList.value.find(p => p.PROJCTSEQ === selectedProject.value.PROJCTSEQ);
if (!original) return false;
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
);
});
// ::
const updateAddress = (addressData) => {
selectedProject.value = {
...selectedProject.value,
PROJCTZIP: addressData.postcode,
PROJCTARR: addressData.address,
PROJCTDTL: addressData.detailAddress
};
};
onMounted(async () => {
await getProjectList();
await userStore.userInfo();
user.value = userStore.user;
const handleUpdate = () => {
if (!hasChanges.value) {
toastStore.onToast('변경된 내용이 없습니다.', 'e');
return;
}
if (startDateInput.value) {
// FormInput input
startInputElement = startDateInput.value.$el.querySelector('input[type="date"]');
}
$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();
}
});
};
if (endDateInput.value) {
endInputElement = endDateInput.value.$el.querySelector('input[type="date"]');
}
});
onMounted(async () => {
await getProjectList();
await userStore.userInfo();
user.value = userStore.user;
});
</script>

View File

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

View File

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

View File

@ -1,5 +1,4 @@
<template>
<form @submit.prevent="handleSubmit">
<div class="col-xl-12">
<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">
<button type="submit" class="btn btn-primary">로그인</button>
<p v-if="errorMessage" class="invalid-feedback d-block mb-0">{{ errorMessage }}</p>
<button type="submit" @click="handleSubmit" class="btn btn-primary">로그인</button>
</div>
<div class="mb-3 d-flex justify-content-around">
@ -23,16 +21,16 @@
<label class="form-check-label fw-bold" for="rememberCheck">&nbsp;자동로그인</label>
</div>
<RouterLink class="text-dark fw-bold" to="/register">등록신청</RouterLink>
<RouterLink class="text-dark fw-bold" to="/pw">비밀번호 재설정</RouterLink>
<RouterLink class="text-dark fw-bold" to="/pw">비밀번호 찾기</RouterLink>
</div>
</div>
</form>
</template>
<script setup>
import $api from '@api';
import router from '@/router';
import { ref } from 'vue';
import { useRoute } from 'vue-router';
import { ref } from 'vue';
import UserFormInput from '@c/input/UserFormInput.vue';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
@ -41,9 +39,9 @@
const idAlert = ref(false);
const passwordAlert = ref(false);
const remember = ref(false);
const errorMessage = ref("");
const userStore = useUserInfoStore();
const route = useRoute();
const handleIdChange = value => {
id.value = value;
@ -55,8 +53,7 @@
passwordAlert.value = false;
};
const handleSubmit = async () => {
errorMessage.value = '';
const handleSubmit = async () => {
idAlert.value = id.value.trim() === '';
passwordAlert.value = password.value.trim() === '';
@ -64,23 +61,16 @@
return;
}
$api.post('user/login', {
loginId: id.value,
password: password.value,
remember: remember.value,
}, { headers: { isLogin: true } })
.then(async res => {
//
if (res.data.code > 10000) {
//
errorMessage.value = res.data.message;
return;
}
//
await userStore.userInfo();
}, { headers: { 'X-Page-Route': route.path } })
.then(res => {
if (res.status === 200) {
userStore.userInfo();
router.push('/');
})
}
});
};
</script>

View File

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

View File

@ -1,12 +1,11 @@
<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
v-for="(user, index) in displayedUserList"
v-for="(user, index) in userList"
:key="index"
class="avatar pull-up "
class="avatar pull-up"
:class="{ 'opacity-100': isUserDisabled(user) }"
@click.stop="showOnlyActive ? null : toggleDisable(index)"
:style="showOnlyActive ? 'cursor: default' : ''"
@click.stop="toggleDisable(index)"
data-bs-toggle="tooltip"
data-popup="tooltip-custom"
data-bs-placement="top"
@ -14,93 +13,33 @@
:data-bs-original-title="getTooltipTitle(user)"
>
<img
class="user-avatar border border-3 rounded-circle object-fit-cover"
class="rounded-circle user-avatar border border-3"
:class="{ 'grayscaleImg': isUserDisabled(user) }"
:src="`${baseUrl}upload/img/profile/${user.MEMBERPRF}`"
:style="`border-color: ${user.usercolor} !important;`"
@error="$event.target.src = '/img/icons/icon.png'"
alt="user"
/>
</li>
</ul>
<span v-else >-</span>
</template>
<script setup>
import { onMounted, ref, nextTick, computed, watch } from 'vue';
import { onMounted, ref, nextTick } from 'vue';
import { useUserStore } from '@s/userList';
import $api from '@api';
import { useToastStore } from "@s/toastStore";
const emit = defineEmits(['user-list-update']);
const userStore = useUserStore();
const userList = ref([]);
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const userProjectPeriods = ref([]);
const toastStore = useToastStore();
const props = defineProps({
projctSeq: {
type: Number,
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 () => {
if (props.projctSeq) {
@ -111,37 +50,9 @@ const fetchProjectParticipation = async () => {
...user,
PROJCTYON: projectMembers.find(pm => pm.MEMBERSEQ === user.MEMBERSEQ)?.PROJCTYON ?? '1'
}));
//
originalDisabledUsers.value = userList.value
.filter(user => user.PROJCTYON === '0')
.map(user => user.MEMBERSEQ);
emitUserListUpdate();
}
}
};
//
const fetchUserProjectPeriods = async () => {
if (props.projctSeq) {
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 () => {
@ -150,56 +61,43 @@ onMounted(async () => {
if (props.projctSeq) {
await fetchProjectParticipation();
await fetchUserProjectPeriods();
} else {
// projctSeq , emit
emitUserListUpdate();
}
nextTick(() => {
initTooltips();
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach((tooltip) => {
new bootstrap.Tooltip(tooltip);
});
});
});
//
const initTooltips = () => {
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach((tooltip) => {
if (tooltip._tooltip) {
tooltip._tooltip.dispose();
}
new bootstrap.Tooltip(tooltip);
});
};
//
const isUserDisabled = (user) => {
return props.projctSeq ? user.PROJCTYON === '0' : user.disabled;
};
// ( API )
const toggleDisable = (index) => {
if (props.showOnlyActive) return;
const user = displayedUserList.value[index];
//
if (props.role === 'vote') {
if (user.MEMBERSEQ === userStore.userInfo.id) {
toastStore.onToast('본인은 비활성화할 수 없습니다.', 'e');
return;
}
}
// / DB
const toggleDisable = async (index) => {
const user = userList.value[index];
if (user) {
//
if (props.projctSeq) {
user.PROJCTYON = user.PROJCTYON === '1' ? '0' : '1';
} else {
user.disabled = !user.disabled;
}
const newParticipationStatus = props.projctSeq
? user.PROJCTYON === '1'
: !user.disabled;
emitUserListUpdate();
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();
}
}
};
@ -210,51 +108,7 @@ const emitUserListUpdate = () => {
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 userName = 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;
return user.MEMBERSEQ === userStore.userInfo.id ? '나' : user.MEMBERNAM;
};
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>

View File

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

View File

@ -1,73 +1,55 @@
<template>
<div class="card mb-6" >
<div class="card-body " :class="{'disabled-class': data.localVote.LOCVOTDDT && (topVoters.length == 1 || data.localVote.LOCVOTRES || voteResult == 0)}" v-if="!data.localVote.LOCVOTDEL" >
<h5 class="card-title mb-1">
<div class="list-unstyled users-list d-flex align-items-center gap-1">
<img
class="object-fit-cover rounded-circle user-avatar border border-3 w-px-40 h-px-40"
:src="`${baseUrl}upload/img/profile/${data.localVote.MEMBERPRF}`"
:style="`border-color: ${data.localVote.usercolor} !important;`"
@error="$event.target.src = '/img/icons/icon.png'"
alt="user"
/>
<div class="w-100">
<div class="d-flex justify-content-between align-items-center">
<div class="user-info">
<h6 class="mb-1">{{ data.localVote.MEMBERNAM }}</h6>
<!-- 투표완료시 -->
</div>
<div class="add-btn d-flex align-items-center">
<!-- 투표작성자만 수정/삭제/종료 가능 -->
<div v-if="userStore.user.id === data.localVote.LOCVOTREG">
<button
v-if="!data.localVote.LOCVOTDDT"
type="button"
class="btn btn-label-danger btn-icon m-1"
@click="endBtn(data.localVote.LOCVOTSEQ)"
><i class="bx bx-power-off"></i>
</button>
<DeleteBtn v-if="!data.localVote.LOCVOTDDT" @click="voteDelete(data.localVote.LOCVOTSEQ)" />
</div>
</div>
</div>
</div>
<div class="card mb-6">
<div class="card-body">
<h5 class="card-title mb-1"><div class="list-group-item list-group-item-action d-flex align-items-center cursor-pointer">
<img
class="rounded-circle user-avatar border border-3 w-px-40"
:src="`${baseUrl}upload/img/profile/${data.localVote.MEMBERPRF}`"
:style="`border-color: ${data.localVote.usercolor} !important;`"
alt="user"
/>
<div class="w-100">
<div class="d-flex justify-content-between">
<div class="user-info">
<h6 class="mb-1">{{ data.localVote.MEMBERNAM }}</h6>
</div>
</h5>
<h5 class="mb-0">{{ data.localVote.LOCVOTTTL }}
<i v-if="yesVotetotal != '0'" class="bx bxs-check-circle link-success"></i>
</h5>
<small >{{ data.localVote.formatted_LOCVOTRDT }} ~ {{ data.localVote.formatted_LOCVOTEDT }}</small>
<!-- 투표안했을시-->
<div v-if="data.localVote.LOCVOTDDT && voteResult == 0">
<small class="text-primary text-uppercase">투표 결과없음 (😂아무도 투표하지 않았습니다)</small>
</div>
<div v-else>
<vote-card-check
v-if="yesVotetotal == 0 && !data.localVote.LOCVOTDDT"
@addContents="addContents"
@checkedNames="checkedNames"
:data="data.voteDetails"
:voteInfo="data.localVote"
: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 class="add-btn">
<!-- 투표완료시 -->
<i class="bx bxs-check-circle link-success"></i>
<!-- 투표작성자만 수정/삭제/종료 가능 -->
<button
v-if="userStore.user.id === data.localVote.LOCVOTREG"
type="button" class="bx btn btn-danger">종료</button>
<EditBtn />
<DeleteBtn />
</div>
<!-- 투표완/미완 인원 -->
<vote-user-list
:data="data.voteMembers"/>
</div>
</div>
<div v-else class="card-body disabled-class">
<h5>{{ data.localVote.LOCVOTTTL }}</h5>
삭제된 투표입니다.
</div>
</div>
</h5>
<h5>{{ data.localVote.LOCVOTTTL }}</h5>
<div class="mb-1">{{ data.localVote.formatted_LOCVOTRDT }} ~ {{ data.localVote.formatted_LOCVOTEDT }}</div>
<!-- 투표완료시-->
<button v-if="data.yesVotetotal > 0" class="btn btn-primary btn-sm" >재투표</button>
<!-- 투표안했을시-->
<vote-card-check
v-if="data.yesVotetotal == 0"
@addContents="addContents"
@checkedNames="checkedNames"
:data="data.voteDetails"
:voteInfo="data.localVote"
:total="data.voteDetails.length "/>
<!-- 투표완/미완 인원 -->
<vote-user-list
:data="data.voteMembers"/>
<!-- 투표 결과 -->
<vote-result-list />
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { ref } from 'vue'
import EditBtn from '@c/button/EditBtn.vue';
import DeleteBtn from '@c/button/DeleteBtn.vue';
import voteUserList from '@c/voteboard/voteUserList.vue';
@ -81,78 +63,19 @@ const props = defineProps({
type: Object,
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 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 checkVoteCompletion = () => {
if (props.data.localVote.total_votable === props.data.localVote.total_voted && props.data.localVote.LOCVOTDDT == null) {
emit('voteEnded', { id: props.data.localVote.LOCVOTSEQ });
}
};
const userStore = useUserInfoStore();
const emit = defineEmits(['addContents','checkedNames']);
const addContents = (itemList, voteId) =>{
emit('addContents',itemList,voteId)
}
const 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>
<style scoped>
.disabled-class {
pointer-events: none; /* 클릭 방지 */
opacity: 0.5; /* 흐리게 표시 */
}
</style>

View File

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

View File

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

View File

@ -1,18 +1,12 @@
<template>
<div>
<card
@addContents="addContents"
@checkedNames="checkedNames"
@endVoteId="endVoteId"
@voteEnded="voteEnded"
@voteDelete="voteDelete"
@randomList="randomList"
@updateVote="updateVote"
v-for="(item, index) in data"
:key="index"
:data="item"
/>
</div>
<div>
<card
@addContents="addContents"
@checkedNames="checkedNames"
v-for="(item, index) in data"
:key="index"
:data="item" />
</div>
</template>
<script setup>
@ -22,30 +16,14 @@ const props = defineProps({
type: Array,
required: true,
},
});
const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','voteDelete','randomList']);
const emit = defineEmits(['addContents','checkedNames']);
const addContents = (itemList ,voteId) =>{
emit('addContents',itemList ,voteId);
}
const 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>

View File

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

View File

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

View File

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

View File

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

View File

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

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