Compare commits

..

3 Commits

117 changed files with 9912 additions and 15797 deletions

View File

@ -1,7 +1,6 @@
VITE_DOMAIN = https://192.168.0.251:5100/
VITE_DOMAIN = https://192.168.0.251:5173/
# VITE_LOGIN_URL = http://localhost:10325/ms/
VITE_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_SERVER = httpS://192.168.0.251:10325/
VITE_API_URL = https://192.168.0.251:10325/api/
VITE_TEST_URL = https://192.168.0.251:10325/test/
VITE_KAKAO_MAP_KEY=6f092e8f45ee81186bb6d8408f66a492

View File

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

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

8485
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,7 +6,7 @@
"scripts": {
"dev": "vite --host 0.0.0.0 --mode dev",
"mine": "vite --host 0.0.0.0 --mode mine",
"build": "vite build --mode dev",
"build": "vite build --mode prod",
"preview": "vite preview",
"lint": "eslint . --fix",
"format": "prettier --write src/"
@ -19,7 +19,6 @@
"@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",

View File

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

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

View File

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

View File

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

View File

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

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,7 +1,6 @@
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,
@ -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,36 @@ $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) {
// 오류 응답 처리
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 (!error.config.headers.isLogin) {
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 +69,4 @@ $api.interceptors.response.use(
},
);
export default $api;

View File

@ -1,20 +1,18 @@
/*
작성자 : 공현지
작성일 : 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 타입을 변환
@ -47,11 +45,11 @@ const common = {
*
* @param {string} dateStr
* @return
* 1. Date type 경우 예시 '2025-02-24 12:02'
* 1. Date type 경우 예시 '25-02-24 12:02'
* 2. Date type 아닌 경우 입력값 리턴
*
*/
dateFormatter(dateStr, type = null) {
dateFormatter(dateStr) {
const date = new Date(dateStr);
const dateCheck = date.getTime();
@ -59,28 +57,13 @@ const common = {
return dateStr;
} else {
const { year, month, day, hours, minutes } = this.formatDateTime(date);
let callback = '';
if (type == 'YMD') {
callback = `${year}-${month}-${day}`;
} else if (type == 'MD') {
callback = `${month}-${day}`;
} else if (type == 'T') {
callback = `${hours}:${minutes}`;
} else {
callback = `${year}-${month}-${day} ${hours}:${minutes}`;
}
return callback;
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
},
formatDateTime(dateStr) {
const date = new Date(dateStr);
const dateCheck = date.getTime();
if (isNaN(dateCheck)) return dateStr;
formatDateTime(date) {
const zeroFormat = num => (num < 10 ? `0${num}` : num);
return {
year: date.getFullYear(),
month: zeroFormat(date.getMonth() + 1),
@ -91,126 +74,13 @@ const common = {
};
},
// 오늘 날짜시간 조회
getToday() {
const date = new Date();
return {
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
hours: date.getHours(),
minutes: date.getMinutes(),
seconds: date.getSeconds(),
};
},
// 해당 날짜가 오늘인지 확인
isToday(dateStr) {
const date = new Date(dateStr);
const dateCheck = date.getTime();
if (isNaN(dateCheck)) return '날짜 타입 에러';
const today = new Date();
return date.toDateString() === today.toDateString();
},
// 해당 월, 일에 맞는 목록 필터링
filterTargetByDate(target, key, month, day) {
if (!Array.isArray(target) || target.length === 0) return [];
return [...target].filter(item => {
if (!item[key]) return false;
const date = new Date(item[key]);
const MatchingMonth = date.getMonth() + 1 === parseInt(month, 10);
const MatchingDay = date.getDate() === parseInt(day, 10);
return MatchingMonth && MatchingDay;
});
},
/**
* 빈값 확인
*
* @param {} obj
* @returns
*/
isNotEmpty(obj) {
if (obj === null || obj === undefined) {
return false;
}
if (typeof obj === 'string' && obj.trim() === '') {
return false;
}
if ((Array.isArray(obj) || obj === Object(obj)) && Object.keys(obj).length === 0) {
return false;
}
if (obj === null || obj === undefined) return false;
if (typeof obj === 'string' && obj.trim() === '') return false;
if ((Array.isArray(obj) || obj === Object(obj)) && Object.keys(obj).length === 0) return false;
return true;
},
/**
* 에디터에 내용이 있는지 확인
*
* @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 {

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

@ -4,31 +4,26 @@
:unknown="comment.author === '익명'"
:isCommentAuthor="isCommentAuthor"
:boardId="comment.boardId"
:profileName="displayName"
:profileName="comment.author"
:date="comment.createdAt"
:comment="comment"
:profileImg="comment.profileImg"
:showDetail="false"
:isLike="!isLike"
:isCommentPassword="isCommentPassword"
:isCommentProfile="true"
:is-edit-pushed="isEditPushed"
:is-delete-pushed="isDeletePushed"
@editClick="handleEditClick"
@deleteClick="$emit('deleteClick', comment)"
@updateReaction="handleUpdateReaction"
/>
<!-- 댓글 비밀번호 입력창 (익명일 경우) -->
<div v-if="currentPasswordCommentId === comment.commentId && unknown && comment.author == '익명'" class="mt-3 w-px-200 ms-auto">
<div v-if="currentPasswordCommentId === comment.commentId && unknown" class="mt-3 w-25 ms-auto">
<div class="input-group">
<input
type="password"
class="form-control"
:value="password"
autocomplete="new-password"
maxlength="8"
placeholder="비밀번호 입력"
@input="filterInput"
@input="$emit('update:password', $event.target.value.trim())"
/>
<button class="btn btn-primary" @click="logPasswordAndEmit">확인</button>
</div>
@ -37,208 +32,149 @@
<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>
<textarea v-model="localEditedContent" class="form-control"></textarea>
<div class="mt-2 d-flex justify-content-end">
<SaveBtn class="btn btn-primary" @click="submitEdit" :isEnabled="disabled"></SaveBtn>
<SaveBtn class="btn btn-primary" @click="submitEdit"></SaveBtn>
</div>
</template>
<template v-else>
<div class="m-0" style="white-space: pre-wrap">{{ comment.content }}</div>
<p class="m-0">{{ comment.content }}</p>
</template>
</div>
<!-- <p>현재 isDeleted : {{ isDeleted }}</p> -->
<!-- <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" :unknown="unknown" @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="child.author === '익명'"
:isPlusButton="false"
:isLike="true"
:isCommentProfile="true"
:isCommentAuthor="child.isCommentAuthor"
:isCommentPassword="isCommentPassword"
:currentPasswordCommentId="currentPasswordCommentId"
:passwordCommentAlert="passwordCommentAlert"
:password="password"
@editClick="handleReplyEditClick"
@deleteClick="$emit('deleteClick', child)"
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent)"
@cancelEdit="$emit('cancelEdit', child)"
@submitComment="submitComment"
@updateReaction="handleUpdateReaction"
@submitPassword="$emit('submitPassword', child, password)"
@update:password="$emit('update:password', $event)"
/>
</li>
</ul>
</div>
</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, computed, watch } from 'vue';
import BoardProfile from './BoardProfile.vue';
import BoardCommentArea from './BoardCommentArea.vue';
import PlusButton from '../button/PlusBtn.vue';
import SaveBtn from '../button/SaveBtn.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: false,
},
isCommentAuthor: {
type: Boolean,
default: false,
},
isPlusButton: {
type: Boolean,
default: true,
},
isLike: {
type: Boolean,
default: false,
},
isEditTextarea: {
type: Boolean,
default: false
},
isCommentPassword: {
type: Boolean,
default: false,
},
passwordCommentAlert: {
type: String,
default: ''
},
currentPasswordCommentId: {
type: Number
},
password:{
type: String
},
});
isPlusButton: {
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', 'editClick', 'deleteClick', 'submitPassword', 'submitEdit', 'cancelEdit', 'update:password']);
const localEditedContent = ref(props.comment.content);
//
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 isEditPushed = ref(false);
const isDeletePushed = ref(false);
};
const displayName = computed(() => {
return props.nickname ? props.nickname : props.comment.author;
});
//
const logPasswordAndEmit = () => {
emit('submitPassword', props.comment, props.password);
};
// emits
const emit = defineEmits([
'submitComment',
'updateReaction',
'editClick',
'deleteClick',
'submitPassword',
'submitEdit',
'cancelEdit',
'update:password',
'inputDetector',
]);
watch(() => props.comment.isEditTextarea, (newVal) => {
if (newVal) {
localEditedContent.value = props.comment.content;
}
});
const filterInput = event => {
event.target.value = event.target.value.replace(/\s/g, ''); //
emit('update:password', event.target.value);
};
//
const submitEdit = () => {
emit('submitEdit', props.comment, localEditedContent.value);
};
const localEditedContent = ref(props.comment.content);
const isModifyContent = ref(props.comment.content);
const disabled = ref(false);
const handleEditClick = () => {
emit('editClick', props.comment);
}
//
const isComment = ref(false);
const toggleComment = () => {
isComment.value = !isComment.value;
};
const handleReplyEditClick = (comment) => {
emit('editClick', comment);
}
//
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,77 +3,46 @@
<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"
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>
<textarea class="form-control" placeholder="댓글 달기" rows="3" v-model="comment"></textarea>
<span v-if="commentAlert" class="invalid-feedback d-block text-start ms-2">{{ commentAlert }}</span>
<span v-else class="invalid-feedback d-block text-start ms-2">{{ textAlert }}</span>
</div>
</div>
<!-- 옵션 버튼 섹션 -->
<div class="d-flex justify-content-between align-items-center mt-1 pb-4">
<!-- 왼쪽: 익명 체크박스 -->
<div v-if="unknown" class="form-check form-check-inline mb-0 me-2">
<input
class="form-check-input"
type="checkbox"
:id="`checkboxAnnonymous${commnetId}`"
v-model="isCheck"
@change="pwd2AlertHandler"
/>
<label class="form-check-label text-nowrap" :for="`checkboxAnnonymous${commnetId}`">익명</label>
</div>
<!-- 중앙: 닉네임 & 비밀번호 입력 필드 (가로 정렬) -->
<div v-if="isCheck" class="d-flex flex-grow-1 gap-2">
<!-- 닉네임 입력 영역 -->
<div class="position-relative">
<input
type="text"
class="form-control mb-1"
v-model="nickname"
placeholder="닉네임"
@input="clearAlert('nickname')"
@keypress="noSpace"
:maxlength="6"
/>
<!-- 닉네임 경고 메시지 -->
<div v-if="nicknameAlert" class="position-absolute text-danger small top-100 start-0">
{{ nicknameAlert }}
</div>
<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 class="form-check-input" type="checkbox" id="inlineCheckbox1" v-model="isCheck" />
<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');
"
placeholder="비밀번호 입력"
/>
<!-- 비밀번호 경고 메시지 -->
<div v-if="passwordAlert2" class="position-absolute text-danger small top-100 start-0">
{{ passwordAlert2 }}
</div>
<span v-if="passwordAlert" class="invalid-feedback d-block text-start ms-2">{{ passwordAlert }}</span>
<span v-else class="invalid-feedback d-block text-start ms-2">{{ passwordAlert2 }}</span>
</div>
</div>
<!-- 오른쪽: 답변 쓰기 버튼 -->
<div class="ms-auto">
<!-- 답변 쓰기 버튼 -->
<div class="ms-auto mt-3 mt-md-0">
<SaveBtn class="btn btn-primary" @click="handleCommentSubmit"></SaveBtn>
</div>
</div>
@ -82,7 +51,7 @@
</template>
<script setup>
import { ref, defineEmits, defineProps, watch, inject } from 'vue';
import { ref, defineEmits, defineProps, computed, watch, inject } from 'vue';
import SaveBtn from '../button/SaveBtn.vue';
const props = defineProps({
@ -102,103 +71,45 @@
type: String,
default: '',
},
maxLength: {
type: Number,
default: 500,
},
commnetId: {
type: Number,
},
});
const noSpace = (e) => {
if (e.key === ' ') e.preventDefault();
};
const $common = inject('common');
const comment = ref('');
const password = ref('');
const isCheck = ref(false);
const isCheck = ref(props.unknown);
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;
//
const LOCBRDTYP = isCheck.value ? '300102' : null;
function handleCommentSubmit() {
if (!$common.isNotEmpty(comment.value)) {
textAlert.value = '댓글을 입력해주세요.';
isValid = false;
textAlert.value = '댓글을 입력하세요';
return false;
} else {
textAlert.value = '';
}
// &
if (isCheck.value) {
if (!$common.isNotEmpty(nickname.value)) {
nicknameAlert.value = '닉네임을 입력해주세요.';
isValid = false;
} else {
nicknameAlert.value = '';
}
if (!$common.isNotEmpty(password.value)) {
passwordAlert2.value = '비밀번호를 입력해주세요.';
password.value = '';
isValid = false;
} else {
passwordAlert2.value = '';
}
if (isCheck.value && !$common.isNotEmpty(password.value)) {
passwordAlert2.value = '비밀번호를 입력하세요';
return false;
} else {
passwordAlert2.value = '';
}
//
if (!isValid) return;
//
emit('submitComment', {
comment: comment.value,
nickname: isCheck.value ? nickname.value : '',
password: isCheck.value ? password.value : '',
isCheck: isCheck.value,
LOCBRDTYP: isCheck.value ? '300102' : null, // '300102'
LOCBRDTYP, // '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();
comment.value = '';
password.value = '';
}
},
);

View File

@ -1,176 +1,119 @@
<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"
:isCommentPassword="isCommentPassword"
:passwordCommentAlert="passwordCommentAlert || ''"
:currentPasswordCommentId="currentPasswordCommentId"
:password="password"
:editCommentAlert="editCommentAlert[comment.commentId]"
:is-edit-pushed="comment.isEditPushed"
:is-delete-pushed="comment.isDeletePushed"
@editClick="handleEditClick"
@deleteClick="handleDeleteClick"
@submitPassword="submitPassword"
@submitComment="submitComment"
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent)"
@submitEdit="handleSubmitEdit"
@cancelEdit="handleCancelEdit"
@updateReaction="reactionData => handleUpdateReaction(reactionData, comment.commentId, comment.boardId)"
@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>
/>
</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,
},
isCommentAuthor: {
type: Boolean,
default: false,
},
isCommentPassword: {
type: Boolean,
default: false,
},
isEditTextarea: {
type: Boolean,
default: false,
},
passwordCommentAlert: {
type: String,
default: ''
},
currentPasswordCommentId: {
type: Number
},
password:{
type: String
},
});
const emit = defineEmits([
'submitComment',
'updateReaction',
'editClick',
'deleteClick',
'submitPassword',
'clearPassword',
'submitEdit',
'update:password',
'inputDetector',
]);
const emit = defineEmits(['submitComment', 'updateReaction', 'editClick', 'deleteClick', 'submitPassword', 'clearPassword','submitEdit', 'update:password']);
const submitComment = replyData => {
emit('submitComment', replyData);
const submitComment = (replyData) => {
emit('submitComment', replyData);
};
const handleUpdateReaction = (reactionData, commentId, boardId) => {
const updatedReactionData = {
...reactionData,
commentId: commentId || reactionData.commentId,
boardId: boardId || reactionData.boardId,
};
const handleUpdateReaction = (reactionData, commentId, boardId) => {
const updatedReactionData = {
...reactionData,
commentId: commentId || reactionData.commentId,
boardId: boardId || reactionData.boardId,
};
emit('updateReaction', updatedReactionData);
}
emit('updateReaction', updatedReactionData);
};
const submitPassword = (comment, password) => {
emit('submitPassword', comment, password);
};
const submitPassword = (comment, password) => {
emit('submitPassword', comment, password);
};
const handleEditClick = (comment) => {
if (comment.parentId) {
emit('editClick', comment); //
} else {
emit('editClick', comment); //
}
};
const handleEditClick = comment => {
if (comment.parentId) {
emit('editClick', comment); //
} else {
emit('editClick', comment); //
}
};
const handleSubmitEdit = (comment, editedContent) => {
emit("submitEdit", comment, editedContent);
};
const handleSubmitEdit = (comment, editedContent) => {
emit('submitEdit', comment, editedContent);
};
const handleDeleteClick = (comment) => {
if (comment.parentId) {
emit('deleteClick', comment); //
} else {
emit('deleteClick', comment); //
}
};
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 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);
};
const updatePassword = (newPassword) => {
emit('update:password', newPassword);
};
</script>

View File

@ -1,23 +1,21 @@
<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 +23,129 @@
<!-- 버튼 영역 -->
<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="isAuthor || showDetail"> -->
<template v-if="unknown || isCommentAuthor || isAuthor">
<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"
@updateReaction="handleUpdateReaction"
/>
>
</BoardRecommendBtn>
</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,
},
// Props
const props = defineProps({
comment: {
type: Object,
required: false,
},
boardId: {
type: Number,
required: false
},
commentId: {
type: Number,
required: false,
},
profileName: {
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,
}
});
const emit = defineEmits(['updateReaction', 'editClick', 'deleteClick']);
//
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 emit = defineEmits(['updateReaction', 'editClick', 'deleteClick']);
const $common = inject('common');
const getProfileImage = (profilePath) => {
return profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile;
};
const isDeletedComment = computed(() => {
return props.comment?.content === '삭제된 댓글입니다' && props.comment?.updateAtRaw !== props.comment?.createdAtRaw;
});
//
const editClick = () => {
emit('editClick', { ...props.comment, unknown: props.unknown });
};
//
const deleteClick = () => {
emit('deleteClick', { ...props.comment, unknown: props.unknown });
};
// /
const handleUpdateReaction = reactionData => {
emit('updateReaction', {
boardId: props.boardId,
commentId: props.comment?.commentId,
...reactionData,
});
};
//
const getProfileImage = profileImg => {
return $common.getProfileImage(profileImg, props.unknown);
};
const setDefaultImage = e => {
return $common.setDefaultImage(e);
};
const showImage = e => {
return $common.showImage(e);
};
</script>
<style scoped>
.profile-detail span ~ span {
margin-left: 5px;
}
.ms-auto button + button {
margin-left: 5px;
}
.btn.author {
height: 30px;
}
@media screen and (max-width: 450px) {
.btn-area {
margin-top: 10px;
width: 100%;
}
.btn.author {
height: 30px;
}
}
</style>

View File

@ -1,89 +1,124 @@
<template v-if="isRecommend">
<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, computed } 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({
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 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 = computed(() => props.comment?.likeCount ?? props.likeCount);
const dislikeCount = computed(() => props.comment?.dislikeCount ?? props.dislikeCount);
watch([() => props.likeClicked, () => props.dislikeClicked], ([likeNewval, dislikeNewval]) => {
likeClicked.value = likeNewval;
dislikeClicked.value = dislikeNewval;
});
const handleLike = () => {
const isLike = !likeClicked.value;
const isDislike = false;
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;
};
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,30 @@
<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" @click="toggleText">
<i :class="buttonClass"></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); //
};
const resetButton = () => {
buttonClass.value = 'bx bx-edit-alt';
};
defineExpose({ resetButton });
import { ref, defineProps } from 'vue';
const props = defineProps({
isToggleEnabled: {
type: Boolean,
default: false,
},
});
const buttonClass = ref("bx bx-edit-alt");
const toggleText = () => {
if (props.isToggleEnabled) {
buttonClass.value = buttonClass.value === "bx bx-edit-alt" ? "bx bx-x" : "bx bx-edit-alt";
}
};
const resetButton = () => {
buttonClass.value = "bx bx-edit-alt";
};
defineExpose({ resetButton });
</script>

View File

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

View File

@ -1,14 +1,13 @@
<template>
<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

@ -5,28 +5,17 @@
</template>
<script setup>
import { ref, defineProps, defineExpose, watch } from 'vue';
import { ref, defineProps, defineExpose } 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";
@ -38,4 +27,4 @@ const resetButton = () => {
};
defineExpose({ resetButton });
</script>
</script>

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

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

View File

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

View File

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

View File

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

View File

@ -41,276 +41,158 @@
<button class="ql-link">Link</button>
<button class="ql-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

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

View File

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

View File

@ -5,26 +5,25 @@
<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"
:min="min"
@focusout="$emit('focusout', modelValue)"
@input="handleInput"
/>
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">
{{ title }} 확인해주세요.
</div>
<!-- 카테고리 중복 -->
<div class="invalid-feedback" :class="isCateAlert ? 'd-block' : ''">
카테고리 중복입니다.
</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 +31,84 @@
<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,
},
isCateAlert : {
type :Boolean,
default: false,
},
isLabel : {
type: Boolean,
default: true,
required: false,
},
disabled: {
type: Boolean,
default: false,
},
min: {
type: String,
default: '',
required: false,
}
});
// Emits
const emits = defineEmits(['update:modelValue', 'focusout', 'update:alert']);
// Emits
const emits = defineEmits(['update:modelValue', 'focusout', 'update:alert']);
// `inputValue`
const inputValue = ref(props.modelValue);
//
watch(inputValue, newValue => {
emits('update:modelValue', newValue);
});
// `inputValue`
const inputValue = ref(props.modelValue);
//
watch(
() => props.modelValue,
newValue => {
if (inputValue.value !== newValue) {
inputValue.value = newValue;
}
},
);
//
watch(inputValue, (newValue) => {
emits('update:modelValue', newValue);
});
const handleInput = event => {
const newValue = event.target.value.slice(0, props.maxlength);
//
watch(() => props.modelValue, (newValue) => {
if (inputValue.value !== newValue) {
inputValue.value = newValue;
}
});
const handleInput = (event) => {
const newValue = event.target.value.slice(0, props.maxlength);
if (newValue.trim() !== '') {
emits('update:alert', false);
}
};
if (newValue.trim() !== '') {
emits('update:alert', false);
}
};
</script>

View File

@ -2,39 +2,27 @@
<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>
<div :class="isRow ? 'col-md-10' : 'col-md-12'" class="d-flex gap-2 align-items-center">
<select class="form-select" :id="name" v-model="selectData" :disabled="disabled" :style="isColor ? { color: selected } : {}">
<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>
<!-- 데이터가 있는 경우 원래 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 v-if="isColor && selected"
class="w-px-40 h-px-30"
:style="{backgroundColor: selected}">
</div>
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div>
<img v-if="isMbti && selected"
role="img"
class="w-px-30 h-px-40"
:src="`/img/mbti/${selected.toLowerCase()}.png`"
alt="MBTI image"/>
</div>
<div v-if="isAlert" class="invalid-feedback">{{ title }} 확인해주세요.</div>
</div>
</template>
@ -82,11 +70,6 @@ const props = defineProps({
default: true,
required: false,
},
isBtn: {
type: Boolean,
default: false,
required: false,
},
isCommon: {
type: Boolean,
default: false,
@ -108,7 +91,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['update:data', 'blur']);
const emit = defineEmits(['update:data']);
const selectData = ref(props.value);
// props.value watch
@ -123,10 +106,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 });
@ -138,11 +117,10 @@ watch(selectData, (newValue) => {
const selected = computed(() => {
//
const selectedItem = props.data.find(item =>
props.isCommon ? item.value === selectData.value : props.data.indexOf(item) === selectData.value
);
return selectedItem ? selectedItem.label : null;
});
</script>

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,73 +1,63 @@
<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">
<h5 class="card-title d-flex justify-content-between">
{{ title }}
<div>
<EditBtn @click.stop="openEditModal" />
<DeleteBtn v-if="isProjectCreator" @click.stop="handleDelete" class="ms-1"/>
<DeleteBtn @click.stop="handleDelete" class="ms-1"/>
</div>
</div>
</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 class="d-flex flex-column flex-sm-row align-items-center pb-2">
<div class="d-flex" @click.stop="isPopoverVisible = !isPopoverVisible">
<i class="bx bxs-map cursor-pointer" ref="mapIconRef"></i>
<div class="ms-2">주소</div>
</div>
<div class="ms-12 position-relative">
{{ address }} {{ addressdtail }}
<!-- 팝오버 -->
<div v-if="isPopoverVisible" class="position-absolute map ">
<button type="button" class="btn-close popover-close" @click.stop="isPopoverVisible = !isPopoverVisible"></button>
<div class="card">
<div class="card-body p-1">
<KakaoMap
v-if="coordinates"
:lat="coordinates.lat"
:lng="coordinates.lng"
class="w-px-200 h-px-200"
>
<KakaoMapMarker
:lat="coordinates.lat"
:lng="coordinates.lng"
/>
</KakaoMap>
</div>
</template>
</MapPopover>
</div>
<div class="col-9 col-md-10 d-flex justify-content-between align-items-center">
<div>{{ address }} {{ addressdtail }}</div>
<button type="button" class="btn text-white" :style="`background-color: ${projctColor} !important;`" @click.stop="openModal">
<i class='bx bx-child'></i>
</button>
</div>
</div>
</div>
<button type="button" class="btn ms-auto text-white" :style="`background-color: ${projctColor} !important;`" @click.stop="openModal">log</button>
</div>
</div>
</div>
@ -75,7 +65,7 @@
<!-- 로그 모달 -->
<CenterModal :display="isModalOpen" @close="closeModal">
<template #title> 등록·수정자 </template>
<template #title> Log </template>
<template #body>
<div v-if="logData.length > 0">
<div
@ -98,7 +88,7 @@
<template #title> 프로젝트 수정 </template>
<template #body>
<FormInput
title="프로젝트명"
title="이름"
name="name"
:is-essential="true"
:is-alert="nameAlert"
@ -113,51 +103,28 @@
:is-essential="true"
:is-label="true"
:is-common="true"
:is-color="true"
:data="allColors"
:value="selectedProject.PROJCTCOL"
@update:data="selectedProject.PROJCTCOL = $event"
/>
<div class="mb-2 row">
<label class="col-md-2 col-form-label">
참여자
</label>
<div class="col-md-10">
<UserList class="m-0"
ref="editUserListRef"
:projctSeq="projctSeq"
:showOnlyActive="false"
@user-list-update="handleEditUserListUpdate"
/>
</div>
</div>
<FormInput
title="시작일"
type="date"
name="startDay"
:is-essential="true"
:modelValue="selectedProject.PROJCTSTR"
@update:modelValue="selectedProject.PROJCTSTR = $event"
/>
<FormInput
title="종료일"
type="date"
name="endDay"
:modelValue="selectedProject.PROJCTEND"
@update:modelValue="selectedProject.PROJCTEND = $event"
/>
<!-- 시작일 -->
<div @click="openStartDatePicker">
<FormInput
title="시작일"
type="date"
name="startDay"
:is-essential="true"
:is-alert="startDayAlert"
:modelValue="selectedProject.PROJCTSTR"
@update:modelValue="selectedProject.PROJCTSTR = $event"
ref="startDateInput"
/>
</div>
<!-- 종료일 -->
<div @click="openEndDatePicker">
<FormInput
title="종료일"
type="date"
name="endDay"
:min="selectedProject.PROJCTSTR"
:modelValue="selectedProject.PROJCTEND"
@update:modelValue="selectedProject.PROJCTEND = $event"
ref="endDateInput"
/>
</div>
<FormInput
title="설명"
name="description"
@ -186,10 +153,11 @@
</template>
<script setup>
import { defineProps, onMounted, ref, computed, watch } from 'vue';
import { defineProps, onMounted, ref, computed, inject } from 'vue';
import UserList from '@c/user/UserList.vue';
import CenterModal from '@c/modal/CenterModal.vue';
import $api from '@api';
import { KakaoMap, KakaoMapMarker } from 'vue3-kakao-maps';
import BackBtn from '@c/button/BackBtn.vue';
import BackButton from '@c/button/BackBtn.vue';
import SaveButton from '@c/button/SaveBtn.vue';
@ -198,16 +166,13 @@ 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';
import commonApi from '@/common/commonApi';
//
const toastStore = useToastStore();
const userStore = useUserInfoStore();
const projectStore = useProjectStore();
// Props
const props = defineProps({
@ -227,7 +192,6 @@ const props = defineProps({
description: {
type: String,
required: false,
default: "",
},
address: {
type: String,
@ -253,18 +217,6 @@ const props = defineProps({
type: String,
required: false
},
projctCreatorId: {
type: Number,
required: false
},
resetUserSelection: {
type: Boolean,
default: false
},
searchParams: {
type: Object,
default: () => ({ text: '', year: null })
}
});
// Emit
@ -274,72 +226,17 @@ const emit = defineEmits(['update']);
const isModalOpen = ref(false);
const logData = ref([]);
const isMapVisible = ref(null);
//
const isPopoverVisible = ref(false);
const mapIconRef = ref(null);
const coordinates = ref(null);
//
const isEditModalOpen = ref(false);
const originalColor = ref('');
const nameAlert = ref(false);
const startDayAlert = ref(false);
const user = ref(null);
const editUserListRef = ref(null);
const userListRef = ref(null);
const selectedUsers = ref({
activeUsers: [],
disabledUsers: []
});
const startDateInput = ref(null);
const endDateInput = ref(null);
// DOM
let startInputElement = null;
let endInputElement = null;
const openStartDatePicker = () => {
if (startInputElement) {
startInputElement.showPicker();
}
};
const openEndDatePicker = () => {
if (endInputElement) {
endInputElement.showPicker();
}
};
const updatePopover = (visible) => {
isMapVisible.value = visible;
};
//
const handleEditUserListUpdate = (userLists) => {
selectedUsers.value = userLists;
};
const isProjectCreator = computed(() => {
return user.value?.id === props.projctCreatorId;
});
// ( )
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,
@ -360,23 +257,12 @@ const { colorList } = commonApi({
colorType: 'YNP',
});
// +
const allColors = computed(() => {
// ( )
const existingColor = {
value: props.projctCol, //
label: props.projctColor //
};
//
const otherColors = colorList.value.filter(color => color.value !== existingColor.value);
//
return [existingColor, ...otherColors];
const existingColor = { value: selectedProject.value.PROJCTCOL, label: selectedProject.value.projctcolor };
return [existingColor, ...colorList.value];
});
// ::
const updateAddress = addressData => {
selectedProject.value = {
@ -387,7 +273,6 @@ const updateAddress = addressData => {
};
};
//
const getLogData = async () => {
const res = await $api.get(`project/log/${props.projctSeq}`);
@ -407,59 +292,26 @@ const closeModal = () => {
};
//
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,
};
const openEditModal = () => {
isEditModalOpen.value = true;
originalColor.value = props.projctCol;
// ( )
if (!user.value) {
userStore.userInfo().then(() => {
user.value = userStore.user;
});
}
};
//
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 ||
return selectedProject.value.PROJCTNAM !== props.title ||
selectedProject.value.PROJCTSTR !== props.strdate ||
selectedProject.value.PROJCTEND !== props.enddate ||
selectedProject.value.PROJCTZIP !== props.addressZip ||
@ -467,37 +319,13 @@ const hasChanges = computed(() => {
selectedProject.value.PROJCTDTL !== props.addressdtail ||
selectedProject.value.PROJCTDES !== props.description ||
selectedProject.value.PROJCTCOL !== props.projctCol;
//
const userChanges = editUserListRef.value?.hasUserChanges() || false;
return basicChanges || userChanges;
});
//
watch(
() => selectedProject.value.PROJCTSTR, // (strdate)
(newStartDate) => {
if (newStartDate && new Date(newStartDate) > new Date(selectedProject.value.PROJCTEND)) {
//
selectedProject.value.PROJCTEND = newStartDate;
}
}
);
// resetUserSelection
watch(() => props.resetUserSelection, () => {
if (editUserListRef.value) {
editUserListRef.value.resetSelection();
}
});
//
const handleUpdate = async () => {
const handleUpdate = () => {
nameAlert.value = selectedProject.value.PROJCTNAM.trim() === '';
startDayAlert.value = selectedProject.value.PROJCTSTR.trim() === '';
if (nameAlert.value || startDayAlert.value) {
if (nameAlert.value) {
return;
}
@ -506,9 +334,7 @@ const handleUpdate = async () => {
return;
}
const disabledMemberSeqs = selectedUsers.value.disabledUsers.map(user => user.MEMBERSEQ);
const res = await $api.patch('project/update', {
$api.patch('project/update', {
projctSeq: selectedProject.value.PROJCTSEQ,
projctNam: selectedProject.value.PROJCTNAM,
projctCol: selectedProject.value.PROJCTCOL,
@ -518,62 +344,59 @@ const handleUpdate = async () => {
projctStr: selectedProject.value.PROJCTSTR,
projctEnd: selectedProject.value.PROJCTEND || null,
projctDes: selectedProject.value.PROJCTDES || null,
projctUmb: user.value?.id,
projctUmb: user.value?.name,
originalColor: originalColor.value === selectedProject.value.PROJCTCOL ? null : originalColor.value,
disabledMembers: disabledMemberSeqs
}).then(res => {
if (res.status === 200) {
toastStore.onToast('수정이 완료 되었습니다.', 's');
closeEditModal();
//
emit('update');
}
});
};
if (res.status === 200) {
toastStore.onToast('수정이 완료 되었습니다.', 's');
//
const convertAddressToCoordinates = () => {
const geocoder = new window.kakao.maps.services.Geocoder();
//
await projectStore.getProjectList(props.searchParams.text, props.searchParams.year, 'false');
await projectStore.getMemberProjects();
await refreshColorList('YNP');
await editUserListRef.value.fetchProjectParticipation();
await userListRef.value.fetchProjectParticipation();
closeEditModal();
emit('update', props.searchParams);
}
geocoder.addressSearch(props.address, (result, status) => {
if (status === window.kakao.maps.services.Status.OK) {
coordinates.value = {
lat: parseFloat(result[0].y),
lng: parseFloat(result[0].x)
};
} else {
// ()
coordinates.value = {
lat: 37.2108651707078,
lng: 127.089445559923
};
}
});
};
//
const handleDelete = () => {
if (confirm('프로젝트를 삭제하시겠습니까?')) {
$api.patch('project/delete', {
projctSeq: props.projctSeq,
projctCol: props.projctCol,
})
.then(res => {
if (res.status === 200) {
toastStore.onToast('프로젝트가 삭제되었습니다.', 's');
projectStore.getProjectList();
projectStore.getMemberProjects();
}
})
}
$api.patch('project/delete', {
projctSeq: props.projctSeq,
projctCol: props.projctCol,
})
.then(res => {
if (res.status === 200) {
toastStore.onToast('삭제가 완료되었습니다.', 's');
location.reload()
}
})
};
//
onMounted(async () => {
convertAddressToCoordinates();
//
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,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,145 @@
<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 p-5">
<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>선물할 연차 개수를 선택하세요.</p>
<div class="justify-content-center d-sm-flex gap-sm-3 align-items-md-center mt-8">
<button @click="decreaseCount" :disabled="grantCount < 2" class="count-btn">-</button>
<span class="text-dark fw-bold fs-4">{{ 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">
<i class="bx bx-gift"></i>
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, watch, onMounted } from "vue";
import axios from "@api";
import { useToastStore } from '@s/toastStore';
const toastStore = useToastStore();
const props = defineProps({
isOpen: Boolean,
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,
},
];
const response = await axios.post("vacation", payload);
if (response.data && response.data.status === "OK") {
toastStore.onToast('연차가 선물되었습니다.', 's');
await fetchSentVacationCount();
emit("updateVacation");
closeModal();
} else {
toastStore.onToast(' 연차 선물 중 오류가 발생했습니다.', 'e');
}
} catch (error) {
toastStore.onToast(' 연차 선물 실패!!.', 'e');
console.error("🚨 연차 추가 실패:", error);
toastStore.onToast(' 연차 선물 실패!!.', 'e');
}
};
};
//
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) {
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-dialog {
background: none !important; /* 배경 제거 */
box-shadow: none !important; /* 음영 제거 */
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* 모달 내용 스타일 */
.modal-content {
background: #fff; /* 기존 흰색 배경 유지 */
border-radius: 8px;
box-shadow: none !important; /* 내부 음영 제거 */
padding: 20px;
max-width: 500px;
width: 100%;
}
</style>

View File

@ -1,126 +1,162 @@
<template>
<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 p-5 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="list-group-numbered px-0 mt-4">
<li
v-for="(vac, index) in mergedVacations"
:key="vac._expandIndex"
class="vacation-item"
>
<!-- Used 항목만 인덱스 표시 -->
<span v-if="vac.category === 'used'" class="fw-bold text-dark me-2">
{{ usedVacationIndexMap[vac._expandIndex] }})
</span>
<span :class="vac.category === 'used' ? 'fw-bold text-danger me-2' : 'fw-bold text-primary me-2'">
{{ vac.category === 'used' ? '-' : '+' }}
</span>
<span
:style="{ color: userColors[vac.senderId || vac.receiverId] || '#000' }"
>
{{ formatDate(vac.date) }}
</span>
</li>
</ol>
</div>
<!-- 연차 데이터 없음 -->
<p v-else class="text-sm-center mt-10 text-gray">
🚫 사용한 연차가 없습니다.
</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;
});
//
/** 모달 닫기 */
const closeModal = () => {
emit("close");
emit("close");
};
</script>
<style scoped>
</style>
<style scoped>
/* 모달 배경 투명하게 */
.modal-dialog {
background: none !important; /* 배경 제거 */
box-shadow: none !important; /* 음영 제거 */
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* 모달 내용 스타일 */
.modal-content {
background: #fff; /* 기존 흰색 배경 유지 */
border-radius: 8px;
box-shadow: none !important; /* 내부 음영 제거 */
padding: 20px;
max-width: 500px;
width: 100%;
}
</style>

View File

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

View File

@ -1,63 +1,31 @@
<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" @input="preventLeadingSpace" />
<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;
},
);
const search = function (event) {
// ( or )
const search = () => {
const trimmedQuery = searchQuery.value.trimStart();
if (trimmedQuery === '') {
emits('update:data', '');
return;
//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);
emits('update:data', event.target.value);
};
//
const preventLeadingSpace = () => {
searchQuery.value = searchQuery.value.trimStart();
};
const preventLeadingSpace = function (event) {
event.target.value = event.target.value.trimStart();
}
</script>

View File

@ -23,7 +23,7 @@
<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>
@ -32,7 +32,7 @@
<script setup>
import $api from '@api';
import router from '@/router';
import { ref } from 'vue';
import { nextTick, ref } from 'vue';
import UserFormInput from '@c/input/UserFormInput.vue';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
@ -71,16 +71,17 @@
remember: remember.value,
}, { headers: { isLogin: true } })
.then(async res => {
//
if (res.data.code > 10000) {
//
errorMessage.value = res.data.message;
return;
if (res.status === 200) {
userStore.userInfo();
await nextTick();
router.push('/')
}
//
await userStore.userInfo();
router.push('/');
})
}).catch(error => {
if (error.response) {
error.config.isLoginRequest = true;
errorMessage.value = error.response.data.message;
console.clear();
}
});
};
</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"
/>
@ -98,12 +94,10 @@
: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
@ -140,7 +134,6 @@
@update:data="handleAddressUpdate"
@update:alert="addressAlert = $event"
:value="address"
:disabled="true"
/>
<UserFormInput
@ -148,12 +141,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>
@ -166,9 +158,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 +176,6 @@
const id = ref('');
const idError = ref('');
const password = ref('');
const passwordError = ref('');
const passwordcheck = ref('');
const passwordcheckError = ref('');
const pwhintRes = ref('');
@ -192,35 +183,28 @@
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 +234,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 +244,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 = '이미 사용 중인 아이디입니다.';
@ -294,90 +272,31 @@
// , 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 +307,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 +316,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 || phoneErrorAlert.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 +336,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 +344,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 sortedUserList"
: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,7 +13,7 @@
:data-bs-original-title="getTooltipTitle(user)"
>
<img
class="user-avatar border border-3 rounded-circle object-fit-cover"
class="rounded-circle user-avatar border border-3"
:class="{ 'grayscaleImg': isUserDisabled(user) }"
:src="`${baseUrl}upload/img/profile/${user.MEMBERPRF}`"
:style="`border-color: ${user.usercolor} !important;`"
@ -23,63 +22,25 @@
/>
</li>
</ul>
<span v-else >-</span>
</template>
<script setup>
import { onMounted, ref, nextTick, computed, watch } from 'vue';
import { onMounted, ref, nextTick, computed } 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) => {
@ -93,14 +54,6 @@ const sortedUserList = computed(() => {
});
});
// showOnlyActive true ,
const displayedUserList = computed(() => {
if (props.showOnlyActive) {
return sortedUserList.value.filter(user => !isUserDisabled(user));
}
return sortedUserList.value;
});
//
const fetchProjectParticipation = async () => {
if (props.projctSeq) {
@ -111,37 +64,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 +75,49 @@ 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 = sortedUserList.value[index];
if (user) {
//
const newParticipationStatus = props.projctSeq
? user.PROJCTYON === '1'
: !user.disabled;
if (props.projctSeq) {
user.PROJCTYON = user.PROJCTYON === '1' ? '0' : '1';
const response = await $api.patch('project/updateYon', {
memberSeq: user.MEMBERSEQ,
projctSeq: props.projctSeq,
projctYon: newParticipationStatus ? '0' : '1'
});
if (response.status === 200) {
// userList
const originalIndex = userList.value.findIndex(u => u.MEMBERSEQ === user.MEMBERSEQ);
if (originalIndex !== -1) {
userList.value[originalIndex].PROJCTYON = newParticipationStatus ? '0' : '1';
}
}
} else {
user.disabled = !user.disabled;
// userList
const originalIndex = userList.value.findIndex(u => u.MEMBERSEQ === user.MEMBERSEQ);
if (originalIndex !== -1) {
userList.value[originalIndex].disabled = newParticipationStatus;
emitUserListUpdate();
}
}
emitUserListUpdate();
}
};
@ -210,51 +128,7 @@ const emitUserListUpdate = () => {
emit('user-list-update', { activeUsers, disabledUsers });
};
//
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,136 @@
<template>
<ul class="row gx-2 mb-0 list-inline ">
<div class="card-body d-flex justify-content-center m-n5">
<ul class="list-unstyled d-flex flex-wrap align-items-center gap-2 mb-0 mt-2">
<li
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 => {
userColors.value[user.MEMBERSEQ] = user.usercolor || "#ccc";
});
nextTick(() => {
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(tooltip => {
new bootstrap.Tooltip(tooltip);
//
userList.value.forEach(user => {
userColors.value[user.MEMBERSEQ] = user.usercolor || "#ccc";
});
});
});
const sortedUserList = computed(() => {
if (!employeeId.value) return [];
nextTick(() => {
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach(tooltip => {
new bootstrap.Tooltip(tooltip);
});
});
});
// ( )
const sortedUserList = computed(() => {
if (!employeeId.value) return userList.value; //
const myProfile = userList.value.find(user => user.MEMBERSEQ === employeeId.value);
const 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;
// URL
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%
}
</style>
// :
const profileSize = computed(() => {
const totalUsers = userList.value.length;
if (totalUsers <= 10) return "68px"; // ~10
if (totalUsers <= 15) return "50px"; // ~20
return "30px"; // 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;
}
/* ul에 flex-wrap을 적용하여 넘치는 프로필이 다음 줄로 내려가도록 함 */
ul {
flex-wrap: wrap;
justify-content: center;
}
/* li 간 간격 조정 */
li {
margin: 5px;
}
</style>

View File

@ -1,65 +1,63 @@
<template>
<div class="card mb-6" >
<div class="card-body " :class="{'disabled-class': data.localVote.LOCVOTDDT && (topVoters.length == 1 || data.localVote.LOCVOTRES || voteResult == 0)}" v-if="!data.localVote.LOCVOTDEL" >
<div class="card mb-6">
<div class="card-body" v-if="!data.localVote.LOCVOTDEL" >
<h5 class="card-title mb-1">
<div class="list-unstyled users-list d-flex align-items-center gap-1">
<img
class="object-fit-cover rounded-circle user-avatar border border-3 w-px-40 h-px-40"
: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>
<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>
<div class="add-btn d-flex align-items-center">
<!-- 투표완료시 -->
<i v-if="data.yesVotetotal == '1'" class="bx bxs-check-circle link-success"></i>
<!-- 투표작성자만 수정/삭제/종료 가능 -->
<div v-if="userStore.user.id === data.localVote.LOCVOTREG">
<button
v-if="!data.localVote.LOCVOTDDT"
type="button"
class="bx btn btn-danger"
@click="endBtn(data.localVote.LOCVOTSEQ)"
>종료</button>
<DeleteBtn @click="voteDelete(data.localVote.LOCVOTSEQ)" />
</div>
</div>
</div>
</div>
</div>
</h5>
<h5 class="mb-0">{{ data.localVote.LOCVOTTTL }}
<i v-if="yesVotetotal != '0'" class="bx bxs-check-circle link-success"></i>
</h5>
<h5 class="mb-1">{{ data.localVote.LOCVOTTTL }}</h5>
<small >{{ data.localVote.formatted_LOCVOTRDT }} ~ {{ data.localVote.formatted_LOCVOTEDT }}</small>
<!-- 투표안했을시-->
<div v-if="data.localVote.LOCVOTDDT && voteResult == 0">
<div v-if="data.localVote.LOCVOTDDT && data.voteResult.length == 0">
<small class="text-primary text-uppercase">투표 결과없음 (😂아무도 투표하지 않았습니다)</small>
</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>
<!-- 투표완/미완 인원 -->
<vote-user-list
:data="data.voteMembers"/>
<vote-card-check
v-if="data.yesVotetotal == 0"
@addContents="addContents"
@checkedNames="checkedNames"
:data="data.voteDetails"
:voteInfo="data.localVote"
:total="data.voteDetails.length "/>
<!-- 투표 결과 -->
<div v-if="data.localVote.LOCVOTDDT" class="mt-3">
<vote-result-list :data="data.voteResult" @randomList="randomList" :randomResultNum="data.localVote.LOCVOTRES"/>
</div>
<!-- 투표완/미완 인원 -->
<vote-user-list
:data="data.voteMembers"/>
</div>
</div>
<div v-else class="card-body disabled-class">
<div v-else class="card-body">
<h5>{{ data.localVote.LOCVOTTTL }}</h5>
삭제된 투표입니다.
</div>
@ -67,7 +65,7 @@
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onMounted, ref } from 'vue'
import EditBtn from '@c/button/EditBtn.vue';
import DeleteBtn from '@c/button/DeleteBtn.vue';
import voteUserList from '@c/voteboard/voteUserList.vue';
@ -86,50 +84,22 @@ const props = defineProps({
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 currentDate = new Date();
const voteEndDate = new Date(props.data.localVote.formatted_LOCVOTEDT.replace(' ', 'T'));
voteEndDate.setDate(voteEndDate.getDate() + 1);
//
const isVoteEnded = computed(() => {
return currentDate > voteEndDate;
});
const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','randomList','voteDelete','updateVote']);
onMounted(() => {
if (isVoteEnded.value && !props.data.localVote.LOCVOTDDT) {
emit('voteEnded', { id: props.data.localVote.LOCVOTSEQ });
emit('voteEnded', { id: props.data.localVote.LOCVOTSEQ });
}
checkVoteCompletion();
});
//
watch(() => props.data.localVote.total_voted, () => {
checkVoteCompletion();
});
//
const checkVoteCompletion = () => {
if (props.data.localVote.total_votable === props.data.localVote.total_voted && props.data.localVote.LOCVOTDDT == null) {
emit('voteEnded', { id: props.data.localVote.LOCVOTSEQ });
}
};
const addContents = (itemList, voteId) =>{
emit('addContents',itemList,voteId)
}
@ -137,6 +107,7 @@ const checkedNames = (numList) =>{
emit('checkedNames',numList);
}
const endBtn = (voteid) =>{
voteEndDate.setTime(currentDate.getTime()); //
emit('endVoteId',voteid);
}
const voteDelete = (voteid) =>{

View File

@ -1,50 +1,43 @@
<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-start">
<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>
<save-btn class="btn-sm mt-2" @click="selectVote"/>
</template>
<script setup>
@ -54,20 +47,20 @@ import SaveBtn from '@c/button/SaveBtn.vue'
import FormInput from '@c/input/FormInput.vue';
import voteCardCheckList from '@c/voteboard/voteCardCheckList.vue';
import { computed, ref } from 'vue';
import LinkInput from "@/components/voteboard/voteLinkInput.vue";
import { voteCommon } from '@s/voteCommon';
import DeleteBtn from "@c/button/DeleteBtn.vue";
import { useToastStore } from '@s/toastStore';
import router from '@/router';
const toastStore = useToastStore();
const contentAlerts = ref([false, false]);
const contentAlerts = ref(false);
const titleAlert = ref(false);
const title = ref('');
const rink = ref('');
const maxLength = ref(2000);
const { itemList, addItem, removeItem } = voteCommon(true);
const total = computed(() => props.total + itemList.value.length);
const isSaveDisabled = computed(() => {
return itemList.value.length === 0 || itemList.value.every(item => !item.content.trim() && !item.url.trim());
return itemList.value.length === 0 || itemList.value.every(item => !item.content.trim());
});
const props = defineProps({
data: {
@ -85,43 +78,18 @@ const props = defineProps({
});
const emit = defineEmits(['addContents','checkedNames']);
//
const addContentSave = (voteId,index) =>{
let valid = true;
const addContentSave = (voteId) =>{
const filteredItemList = itemList.value.filter(item => item.content && item.content.trim() !== '');
itemList.value.forEach((item, index) => {
if (!item.content.trim() && item.url.trim()) {
contentAlerts.value[index] = true;
valid = false;
} else {
contentAlerts.value[index] = false;
}
});
if(valid){
emit('addContents',filteredItemList,voteId);
itemList.value = [{ content: "", url: "" }];
removeItem();
}
emit('addContents',filteredItemList,voteId);
itemList.value = [{ content: "", url: "" }];
}
const ValidHandler = (field) => {
if (field.startsWith('content')) {
const index = parseInt(field.replace('content', '')) - 1;
if (!isNaN(index)) {
contentAlerts.value[index] = false;
}
}
};
const checkedNames = ref([]); //
const updateCheckedNames = (newValues) => {
checkedNames.value = newValues;
};
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,18 @@
<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"
@endVoteId="endVoteId"
@voteEnded="voteEnded"
@voteDelete="voteDelete"
@randomList="randomList"
@updateVote="updateVote"
v-for="(item, index) in data"
:key="index"
:data="item"
/>
</div>
</template>
<script setup>
@ -24,7 +24,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','voteDelete','randomList']);
const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','voteDelete']);
const addContents = (itemList ,voteId) =>{
emit('addContents',itemList ,voteId);
}

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

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,13 @@
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;`"
@error="$event.target.src = '/img/icons/icon.png'"
alt="user"
/>
</li>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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