Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 898955ab35 | |||
| 71f6abc61f |
7
.env.dev
@ -1,7 +0,0 @@
|
||||
VITE_DOMAIN = https://192.168.0.251:5100/
|
||||
# VITE_LOGIN_URL = http://localhost:10325/ms/
|
||||
VITE_SERVER = https://192.168.0.251:10300/
|
||||
VITE_API_URL = https://192.168.0.251:10300/api/
|
||||
VITE_TEST_URL = https://192.168.0.251:10300/test/
|
||||
VITE_SERVER_IMG_URL = https://192.168.0.251:10300/upload/img/
|
||||
VITE_KAKAO_MAP_KEY=6f092e8f45ee81186bb6d8408f66a492
|
||||
@ -1,7 +0,0 @@
|
||||
VITE_DOMAIN = http://localhost:5173/
|
||||
# VITE_LOGIN_URL = http://localhost:10325/ms/
|
||||
VITE_SERVER = http://localhost:10325/
|
||||
VITE_API_URL = http://localhost:10325/api/
|
||||
VITE_TEST_URL = http://localhost:10325/test/
|
||||
VITE_SERVER_IMG_URL = http://localhost:10325/upload/img/
|
||||
VITE_KAKAO_MAP_KEY=6f092e8f45ee81186bb6d8408f66a492
|
||||
@ -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" />
|
||||
@ -73,7 +74,7 @@
|
||||
<script src="/vendor/libs/hammer/hammer.js"></script>
|
||||
<script src="/vendor/libs/i18n/i18n.js"></script>
|
||||
<script src="/vendor/libs/typeahead-js/typeahead.js"></script>
|
||||
<script src="/vendor/js/menu.js"></script>
|
||||
<!-- <script src="/vendor/js/menu.js"></script> -->
|
||||
<!-- endbuild -->
|
||||
|
||||
<!-- Vendors JS -->
|
||||
@ -83,9 +84,6 @@
|
||||
<!-- Page JS -->
|
||||
<!-- <script src="/js/dashboards-analytics.js"></script> -->
|
||||
|
||||
<!-- daum address -->
|
||||
<script src="//t1.daumcdn.net/mapjsapi/bundle/postcode/prod/postcode.v2.js"></script>
|
||||
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
41
jenkinsfile
@ -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
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,8 +6,8 @@
|
||||
"@/*" : ["src/*"],
|
||||
"@a/*": ["src/assets/*"],
|
||||
"@c/*": ["src/components/*"],
|
||||
"@v/*": ["src/view/*"],
|
||||
"@l/*": ["src/layout/*"],
|
||||
"@v/*": ["src/views/*"],
|
||||
"@l/*": ["src/layouts/*"],
|
||||
"@s/*": ["src/stores/*"],
|
||||
"@p/*": ["src/common/plugin/*"],
|
||||
"@api": ["./src/common/axios-interceptor.js"]
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDnTJneuEjlCk/g
|
||||
mzUQyeI9xWdr5TiYz+/HVdIR91PlswECLXiW+j420ARQ+Dpx8JY1sts9kMki/jnD
|
||||
8cNFMwSOnJADwyW+bZZYAdGd/OpyyNQWjm903pKntBKDYULebEnviMDQz7+M6J9Y
|
||||
cVqWSRTMR6VizSYIQ0vIUqQZaISp/TKLvTssjLjwz6DIYtZ2GQ06lAZYmvX6UhSl
|
||||
32xJQ6GWf4Jnemn/2bKYFGk88d4ORfrhpD1JV3lGQGk0HU/pI8R017pKxq9lq/c4
|
||||
d8hqN0CdkQUv0lFw6DQgm3etOVOajlY4eDj+Q3mDCERT8meH1PXfuTsHBtTMHxoA
|
||||
iRtu4YoZAgMBAAECggEBAMti1DrAGXktpCeA0xy8KTDgEJ0TprzYu6Owl1QtA50L
|
||||
1msvyMYZrfNM3z7Dx8DBKZR2fcqZMgSPQARI5shGoE825HwqcVoNyxIAJ26hIxdj
|
||||
+PsMrH076gGFmnHkaIRk/G6g9cunomwpcVS3+EwGXz9yEL/cXQEPC+hOovWkrmM6
|
||||
Ec1oAsqs9DjrK+HzOOcaBuv0Rz9pI7Gob5LQAp8tqOCI4CvP6sdfooSsucCoqB6V
|
||||
xQAakXbsidamWcLtYkTjY2zYVhHvVMk1H5krfgrsoGIaym/QTnk+YQYFd5jt4FiJ
|
||||
ziLZXiZJOeJXJVdlAcJF9aUlO48OAKJeBoq3NkcbxbECgYEA+a2pJ6BN8xKvWyZJ
|
||||
QnpWL5irVKUJF7l1cFvaNJJu4XMS3CfJqdA9X0b7Zuu/8zOdj+5eNpK6Mpz65+mx
|
||||
+/ToYNYoMewFXlfDpcIpT4FdBJAKsKMua2UlTzOI7DxSrcAGD0nItK3ZovpzmNJo
|
||||
H90maU0gib0CSsFVvsAsnVVSawcCgYEA7SfP8tc23txMbLzz+9DjlmeqT2v1XI/P
|
||||
QZEwCO6AIjbbJw3iFYjIgkd86gVGRwIdYEfNoiIk8KT4dMsW4jiwalFa/fA+HW69
|
||||
pqMf1PFnxNPZim592EANVjzzkN4jm63QzIAFiGcN2K99ltjUWrfrzLkkiFR6ENHF
|
||||
dgIpWTyAed8CgYBH/CCr8xTmQvnZzsUKbJkSqfKjud1QQMEyPtk/lQRw7at/W91R
|
||||
n19CbAWpm8jAxp3j1HbHRzB1zTqtyHvvR6ID4Vq/Yev+UlFvJfahHIwD97+NQ87r
|
||||
WcMS/am5an7v51AX8k7ygLkhuxG++tLYdPtRGtKJw7u4b9tX3rI+Pk4/2wKBgQCn
|
||||
CIZ3ZMuZ1hHh+Ifj0bGqSqNywvgS1JtGdAsgD1OiRX6/mBCn2CpZUB6T+VkRRFUK
|
||||
bihQTLo14Au6vxwEA6eFin2LI72sH0ZmarhN1CWhRREQZlguipaaKd3nJ/5udNL+
|
||||
ZiD/fI4NEzVinJ+csbPcAn7PoqhC1my8fDNBTdKzgwKBgQCvH0MEpkZefqN82CNn
|
||||
CuJeQYb48mkFgihICeTsfIeG7XsGqfCOlzbJqxCbTX+Na7FUdtmtJUznK+rVGOPh
|
||||
p+pAw8RbZSIvgzCO1vv0wSHsXxXsieOgwJPZeQqsBWhRs77Ggf9jhIzxcQJuIor3
|
||||
l7Nxg0eoiqP/rYFyOh83nebPQg==
|
||||
-----END PRIVATE KEY-----
|
||||
BIN
localhost.p12
@ -1,26 +0,0 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIEUjCCArqgAwIBAgIQA9mbF03CznoBZ2TyJTPO8jANBgkqhkiG9w0BAQsFADCB
|
||||
jTEeMBwGA1UEChMVbWtjZXJ0IGRldmVsb3BtZW50IENBMTEwLwYDVQQLDChERVNL
|
||||
VE9QLVNHRkwzNExcaG9zdHBhcmtAREVTS1RPUC1TR0ZMMzRMMTgwNgYDVQQDDC9t
|
||||
a2NlcnQgREVTS1RPUC1TR0ZMMzRMXGhvc3RwYXJrQERFU0tUT1AtU0dGTDM0TDAe
|
||||
Fw0yNTAyMTQwMzU2MTJaFw0yNzA1MTQwMzU2MTJaMFwxJzAlBgNVBAoTHm1rY2Vy
|
||||
dCBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTExMC8GA1UECwwoREVTS1RPUC1TR0ZM
|
||||
MzRMXGhvc3RwYXJrQERFU0tUT1AtU0dGTDM0TDCCASIwDQYJKoZIhvcNAQEBBQAD
|
||||
ggEPADCCAQoCggEBAOdMmd64SOUKT+CbNRDJ4j3FZ2vlOJjP78dV0hH3U+WzAQIt
|
||||
eJb6PjbQBFD4OnHwljWy2z2QySL+OcPxw0UzBI6ckAPDJb5tllgB0Z386nLI1BaO
|
||||
b3Tekqe0EoNhQt5sSe+IwNDPv4zon1hxWpZJFMxHpWLNJghDS8hSpBlohKn9Mou9
|
||||
OyyMuPDPoMhi1nYZDTqUBlia9fpSFKXfbElDoZZ/gmd6af/ZspgUaTzx3g5F+uGk
|
||||
PUlXeUZAaTQdT+kjxHTXukrGr2Wr9zh3yGo3QJ2RBS/SUXDoNCCbd605U5qOVjh4
|
||||
OP5DeYMIRFPyZ4fU9d+5OwcG1MwfGgCJG27hihkCAwEAAaNeMFwwDgYDVR0PAQH/
|
||||
BAQDAgWgMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB8GA1UdIwQYMBaAFN7IkO4WB6E9
|
||||
uTxB+KENPr8pN9V4MBQGA1UdEQQNMAuCCWxvY2FsaG9zdDANBgkqhkiG9w0BAQsF
|
||||
AAOCAYEAa9kWqz5NvJo1+9lzTM+QwjeRL7wsfeygdwIx3PRn/0bbyglUO+MhR6oK
|
||||
cbjzpKj+2C5sWuuNSIOGcU95Dnh6ekQtxjSY3j7gbwOiYmwYj4LVRF9KIeGQgW72
|
||||
kHA+tnuEsAhe33mloJhGjrZ/cqkxPz31foVpOpeP0l85NTzXGfyDjePivlgfbCUT
|
||||
8juBEIGD1Go3PTrLoNC0P/1lJAgc1+lGEY2veGQNMqy6TXIhLLHMuXdSEDqQJxjB
|
||||
N6fNzfZh163jgI4UCpmowoLp6oO5iOlM3cxzsfwGpubf7W9nUOyAO5B4VzsTvqYe
|
||||
MLfiUKZXlwUb9eyhIhk0UhgCM4IelcRMUH5nLDn6a2Pyu3bs4TpJ1zTmRZt7PjsX
|
||||
0HllN2/xkp2XRdSLutGTrya5zqo4nLaDa67sTt5WhDp+JRgA3rb5Sgcw78pYEfFq
|
||||
5IGuKZsuSMy6qZFbTAJVINPKwkH6eBAQcr3PyyCMKdJDFkeVYeuqef5N2u/GpGKO
|
||||
DQ0E7Vhc
|
||||
-----END CERTIFICATE-----
|
||||
8890
package-lock.json
generated
94
package.json
@ -1,52 +1,46 @@
|
||||
{
|
||||
"name": "front",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --mode dev",
|
||||
"mine": "vite --host 0.0.0.0 --mode mine",
|
||||
"build": "vite build --mode dev",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --fix",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.15",
|
||||
"@fullcalendar/daygrid": "^6.1.15",
|
||||
"@fullcalendar/interaction": "^6.1.15",
|
||||
"@fullcalendar/vue3": "^6.1.15",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@tinymce/tinymce-vue": "^5.1.1",
|
||||
"@vueup/vue-quill": "^1.2.0",
|
||||
"@vueuse/core": "^13.0.0",
|
||||
"axios": "^1.7.9",
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"dompurify": "^3.2.3",
|
||||
"flatpickr": "^4.6.13",
|
||||
"front": "file:",
|
||||
"heic2any": "^0.0.4",
|
||||
"pinia": "^2.2.6",
|
||||
"pinia-plugin-persist": "^1.0.0",
|
||||
"quill": "^2.0.3",
|
||||
"upload-images-converter": "^2.0.2",
|
||||
"vite-plugin-mkcert": "^1.17.6",
|
||||
"vue": "^3.5.13",
|
||||
"vue-flatpickr-component": "^11.0.5",
|
||||
"vue-router": "^4.4.5",
|
||||
"vue3-kakao-maps": "^2.3.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/eslint-config-prettier": "^10.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.30.0",
|
||||
"prettier": "^3.3.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-inspect": "^0.8.9",
|
||||
"vite-plugin-vue-devtools": "^7.6.5"
|
||||
}
|
||||
"name": "front",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --mode dev",
|
||||
"build": "vite build --mode prod",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --fix",
|
||||
"format": "prettier --write src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fullcalendar/core": "^6.1.15",
|
||||
"@fullcalendar/daygrid": "^6.1.15",
|
||||
"@fullcalendar/interaction": "^6.1.15",
|
||||
"@fullcalendar/vue3": "^6.1.15",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@tinymce/tinymce-vue": "^5.1.1",
|
||||
"@vueup/vue-quill": "^1.2.0",
|
||||
"axios": "^1.7.9",
|
||||
"bootstrap": "^5.3.3",
|
||||
"dayjs": "^1.11.13",
|
||||
"flatpickr": "^4.6.13",
|
||||
"front": "file:",
|
||||
"heic2any": "^0.0.4",
|
||||
"pinia": "^2.2.6",
|
||||
"pinia-plugin-persist": "^1.0.0",
|
||||
"quill": "^2.0.3",
|
||||
"upload-images-converter": "^2.0.2",
|
||||
"vue": "^3.5.13",
|
||||
"vue-flatpickr-component": "^11.0.5",
|
||||
"vue-router": "^4.4.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.14.0",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"@vue/eslint-config-prettier": "^10.1.0",
|
||||
"eslint": "^9.14.0",
|
||||
"eslint-plugin-vue": "^9.30.0",
|
||||
"prettier": "^3.3.3",
|
||||
"vite": "^5.4.10",
|
||||
"vite-plugin-inspect": "^0.8.9",
|
||||
"vite-plugin-vue-devtools": "^7.6.5"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,2 +1,6 @@
|
||||
/* 여기에 dark css 작성 */
|
||||
|
||||
|
||||
.display-block {
|
||||
display: block !important;
|
||||
}
|
||||
@ -1,834 +1,6 @@
|
||||
/* 여기에 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;
|
||||
}
|
||||
/* 오전 반차 그래프 (왼쪽 절반) */
|
||||
.fc-daygrid-event.half-day-am {
|
||||
width: 50% !important;
|
||||
height: 8px !important;
|
||||
border-radius: 2px !important;
|
||||
font-size: 0px !important;
|
||||
margin-left: -0.5% !important;
|
||||
}
|
||||
/* 오후 반차 그래프 (오른쪽 절반) */
|
||||
.fc-daygrid-event.half-day-pm {
|
||||
width: 50% !important;
|
||||
height: 8px !important;
|
||||
margin-left: auto !important;
|
||||
border-radius: 2px !important;
|
||||
font-size: 0px !important;
|
||||
margin-right: -0.5% !important;
|
||||
}
|
||||
/* 연차 그래프 (풀) */
|
||||
.fc-daygrid-event.full-day {
|
||||
width: 100% !important;
|
||||
height: 8px !important;
|
||||
margin-left: auto !important;
|
||||
border-radius: 2px !important;
|
||||
font-size: 0px !important;
|
||||
}
|
||||
/* 공휴일,일요일 색상 */
|
||||
.fc-day-sun .fc-daygrid-day-number,
|
||||
.fc-col-header-cell:first-child .fc-col-header-cell-cushion {
|
||||
color: #ff4500 !important;
|
||||
}
|
||||
/* 토요일 색상 */
|
||||
.fc-day-sat .fc-daygrid-day-number,
|
||||
.fc-col-header-cell:last-child .fc-col-header-cell-cushion {
|
||||
color: #6076e0 !important;
|
||||
}
|
||||
/* 캘린더 날짜 왼쪽 상단 위치하게 */
|
||||
.fc-daygrid-day-number {
|
||||
margin-right: auto;
|
||||
}
|
||||
/* 데이트피커 뾰족없게 */
|
||||
.flatpickr-calendar:before,
|
||||
.flatpickr-calendar:after {
|
||||
display: none !important;
|
||||
}
|
||||
/* 기본 스타일은 그대로 두고, 데이트피커 인풋의 추가 스타일 정의 */
|
||||
.fc-toolbar-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
.fc-today-button {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
/* 클릭 가능한 날짜 */
|
||||
.fc-daygrid-day.clickable {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease-in-out;
|
||||
}
|
||||
/* 마우스를 올렸을 때 효과 */
|
||||
.fc-daygrid-day.clickable:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05); /* 연한 배경 효과 */
|
||||
}
|
||||
/* 주말 (토요일, 일요일) 및 공휴일 */
|
||||
.fc-day-sat-sun {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
/* 과거 날짜 (오늘 -7일일) */
|
||||
.fc-daygrid-day.past {
|
||||
cursor: not-allowed !important;
|
||||
}
|
||||
/* 기본 이벤트 스타일 */
|
||||
.fc-daygrid-event {
|
||||
border: none !important;
|
||||
border-radius: 4px;
|
||||
}
|
||||
/* 오전 반차 활성화 영역 (왼쪽 절반) */
|
||||
.selected-event.half-day-am {
|
||||
width: 50% !important;
|
||||
left: 0 !important;
|
||||
border-top-left-radius: 4px;
|
||||
border-bottom-left-radius: 4px;
|
||||
}
|
||||
/* 오후 반차 활성화 영역 (오른쪽 절반) */
|
||||
.selected-event.half-day-pm {
|
||||
width: 50% !important;
|
||||
margin-left: auto !important;
|
||||
border-top-right-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
}
|
||||
|
||||
/* 휴가 모달 */
|
||||
.vac-modal-dialog {
|
||||
background: none !important;
|
||||
box-shadow: none !important;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
.vac-modal-content {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
box-shadow:
|
||||
0px -4px 5px rgba(0, 0, 0, 0.1),
|
||||
0px 4px 0px rgba(0, 0, 0, 0);
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
.vac-modal-body {
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.vac-modal-text {
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.count-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.count-value {
|
||||
font-size: 23px;
|
||||
font-weight: bold;
|
||||
min-width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
.custom-button {
|
||||
background: none;
|
||||
border: none;
|
||||
width: 55px;
|
||||
height: 55px;
|
||||
font-size: 26px;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.vac-modal-title {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.vacation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.close-btn:hover {
|
||||
color: #525252;
|
||||
}
|
||||
.count-btn {
|
||||
font-size: 17px;
|
||||
padding: 2px 10px;
|
||||
border: none;
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.count-btn:hover {
|
||||
background: #1d2c44;
|
||||
}
|
||||
.count-btn:disabled {
|
||||
background: #cccccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.custom-button-container {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
.custom-button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.custom-button i {
|
||||
color: #282538;
|
||||
font-size: 25px;
|
||||
}
|
||||
.custom-button:hover i {
|
||||
color: #ff0800;
|
||||
}
|
||||
.custom-button:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 휴가 사원프로필 */
|
||||
.profile-list {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 오전/오후반차,저장버튼 */
|
||||
/* 버튼 기본 스타일 */
|
||||
.vac-btn {
|
||||
transition: all 0.2sease-in-out;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
/* 마우스를 올렸을 때 */
|
||||
.vac-btn:hover {
|
||||
filter: brightness(90%);
|
||||
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
/* 버튼이 눌렸을 때 */
|
||||
.vac-btn:active {
|
||||
transform: scale(0.9);
|
||||
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
/* 선택된 (눌린) 버튼 */
|
||||
.vac-btn.active {
|
||||
box-shadow: 0px 4px 15px rgba(224, 224, 224, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.vac-btn-warning {
|
||||
color: #fff;
|
||||
background-color: #ffc144;
|
||||
border-color: #ffc144;
|
||||
box-shadow: 0 0.125rem 0.25rem 0 rgba(255, 171, 0, 0.4);
|
||||
font-size: 28px;
|
||||
}
|
||||
/* AM 버튼 (선택된 상태) */
|
||||
.vac-btn-warning.active {
|
||||
background-color: #ff7300 !important;
|
||||
color: #fff;
|
||||
}
|
||||
.vac-btn-info {
|
||||
color: #fff;
|
||||
background-color: #03c3ec;
|
||||
border-color: #03c3ec;
|
||||
box-shadow: 0 0.125rem 0.25rem 0 rgba(3, 195, 236, 0.4);
|
||||
font-size: 28px;
|
||||
}
|
||||
/* PM 버튼 (선택된 상태) */
|
||||
.vac-btn-info.active {
|
||||
background-color: #0b5ed7 !important;
|
||||
color: white;
|
||||
}
|
||||
/* 풀 연차 버튼 스타일 */
|
||||
.vac-btn-primary {
|
||||
color: #fff;
|
||||
background-color: #49d46a; /* 녹색 */
|
||||
border-color: #49d46a;
|
||||
box-shadow: 0 0.125rem 0.25rem 0 rgba(40, 167, 69, 0.4);
|
||||
font-size: 28px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
/* 풀 연차 버튼 활성화 스타일 */
|
||||
.vac-btn-primary.active {
|
||||
background-color: #009124 !important;
|
||||
color: #fff;
|
||||
border: 3px solid #91d091 !important;
|
||||
box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
/* 풀 연차 버튼이 눌렸을 때 효과 */
|
||||
.vac-btn-primary:active {
|
||||
transform: scale(0.9);
|
||||
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
/* 버튼 기본 */
|
||||
.vac-btn-success {
|
||||
transition: all 0.2s ease-in-out;
|
||||
background-color: #871919 !important;
|
||||
color: white;
|
||||
border: 2px solid transparent;
|
||||
font-size: 30px;
|
||||
}
|
||||
/* 버튼 활성화 */
|
||||
.vac-btn-success.active {
|
||||
background-color: #ff0000 !important;
|
||||
color: white !important;
|
||||
border: 3px solid #eb9f9f !important;
|
||||
box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.3);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
/* 버튼 비활성화 */
|
||||
.vac-btn-success.disabled {
|
||||
background-color: #bbb8b8 !important;
|
||||
color: white !important;
|
||||
cursor: not-allowed !important;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* 작은 화면에서 버튼 크기 조정 */
|
||||
@media (max-width: 1700px) {
|
||||
.count-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
font-size: 15px;
|
||||
}
|
||||
.count-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.count-value {
|
||||
font-size: 15px;
|
||||
}
|
||||
.custom-button {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
font-size: 22px;
|
||||
}
|
||||
.vac-grant-modal-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
.vac-modal-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
.vac-modal-title {
|
||||
font-size: 15px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.vacation-item {
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.vac-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.vac-btn-success {
|
||||
font-size: 20px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1500px) {
|
||||
.vac-grant-modal-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
.vac-modal-text {
|
||||
font-size: 11px;
|
||||
}
|
||||
.vac-modal-title {
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.close-btn {
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.vacation-item {
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.vac-btn {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.vac-btn-success {
|
||||
font-size: 15px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.grayscaleImg {
|
||||
filter: grayscale(100%);
|
||||
}
|
||||
|
||||
/* scrollbar 안보이게 */
|
||||
.scrollbar-none {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
/* project list */
|
||||
|
||||
.hidden-start-input {
|
||||
position: absolute;
|
||||
top: 103%;
|
||||
left: 20%;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.hidden-end-input {
|
||||
position: absolute;
|
||||
top: 113.3%;
|
||||
left: 20%;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.map {
|
||||
top: -160px;
|
||||
left: 90px;
|
||||
}
|
||||
|
||||
@keyframes sparkle {
|
||||
0% {
|
||||
color: #ffcc00;
|
||||
}
|
||||
50% {
|
||||
color: red;
|
||||
}
|
||||
100% {
|
||||
color: #ffcc00;
|
||||
}
|
||||
}
|
||||
|
||||
.bxs-map {
|
||||
animation: sparkle 1s infinite; /* 1초마다 반복 */
|
||||
}
|
||||
|
||||
.popover-close {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 2;
|
||||
background-color: #fff !important;
|
||||
box-shadow: 0 0.125rem 0.25rem rgba(161, 172, 184, 0.4);
|
||||
transition: all 0.23s ease 0.1s;
|
||||
transform: translate(23px, -25px);
|
||||
}
|
||||
|
||||
.popover-close:hover {
|
||||
opacity: 1;
|
||||
outline: none;
|
||||
transform: translate(20px, -20px);
|
||||
}
|
||||
|
||||
.end-project {
|
||||
background-color: #ddd !important;
|
||||
}
|
||||
/* project list end */
|
||||
|
||||
/* commuters */
|
||||
.commuter-list {
|
||||
max-height: 450px;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.fc-daygrid-day[data-has-commuters='true'] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* commuters end */
|
||||
|
||||
/* Scroll Button */
|
||||
|
||||
.scroll-top-btn {
|
||||
bottom: 40px;
|
||||
right: 21.7%;
|
||||
transition:
|
||||
opacity 0.4s ease,
|
||||
visibility 0.4s ease,
|
||||
transform 0.4s ease;
|
||||
}
|
||||
.scroll-top-btn.visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.scroll-top-btn.hidden {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
.back-btn {
|
||||
bottom: 40px;
|
||||
right: 21.7%;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
.back-btn.shifted {
|
||||
transform: translateY(-50px);
|
||||
}
|
||||
|
||||
/* Scroll Button end */
|
||||
|
||||
/* 중앙 콘텐츠 자동 조정 */
|
||||
.layout-page {
|
||||
flex-grow: 1;
|
||||
min-width: 0; /* flexbox 내에서 올바른 크기 계산 */
|
||||
margin-right: 20%; /* 채팅 사이드바의 너비만큼 밀리도록 설정 */
|
||||
}
|
||||
|
||||
/* 중앙 콘텐츠 자동 조정 end */
|
||||
|
||||
/* media */
|
||||
/* 탑바 범위조정 */
|
||||
.layout-navbar-fixed .layout-navbar.navbar-detached {
|
||||
width: calc(100% - 1.625rem * 2 - 16.25rem - 20%) !important;
|
||||
}
|
||||
|
||||
@media (min-width: 1200px) {
|
||||
nav#layout-navbar {
|
||||
left: calc(16.25rem - 20%) !important;
|
||||
}
|
||||
|
||||
.layout-navbar.navbar-detached.container-xxl {
|
||||
max-width: calc(1440px - 1.625rem * 2) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 탑바 범위조정(1200px 이하) */
|
||||
@media (max-width: 1200px) {
|
||||
nav#layout-navbar {
|
||||
left: -20% !important;
|
||||
}
|
||||
|
||||
.layout-navbar-fixed .layout-navbar.navbar-detached {
|
||||
width: calc(100% - 1.625rem * 2 - 20%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 탑바 범위조정(992px 이하) */
|
||||
@media (max-width: 992px) {
|
||||
.layout-navbar-fixed .layout-navbar.navbar-detached {
|
||||
width: calc(100% - 2rem - 20%) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile */
|
||||
/* 가로모드 모바일 디바이스 (가로 해상도가 768px 보다 작은 화면에 적용) */
|
||||
@media (max-width: 767px) {
|
||||
.chat-sidebar {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
nav#layout-navbar {
|
||||
left: 0 !important;
|
||||
}
|
||||
|
||||
.layout-navbar-fixed .layout-navbar.navbar-detached {
|
||||
width: calc(100% - 2rem) !important;
|
||||
}
|
||||
|
||||
.layout-page {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
#app-calendar-sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 세로모드 모바일 디바이스 (가로 해상도가 576px 보다 작은 화면에 적용) */
|
||||
@media (max-width: 575px) {
|
||||
}
|
||||
|
||||
/* Mobile end */
|
||||
/* media end */
|
||||
|
||||
/* BoardComment */
|
||||
|
||||
.beforeRotate {
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.rotate {
|
||||
transform: rotate(45deg);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
/* BoardComment end */
|
||||
|
||||
/* vote */
|
||||
.hidden-date-input {
|
||||
position: absolute;
|
||||
top: 31.5%;
|
||||
left: 17%;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hidden-time-input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 권한부여 */
|
||||
.user-card-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 20px;
|
||||
justify-content: center;
|
||||
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;
|
||||
}
|
||||
.display-block {
|
||||
display: block !important;
|
||||
}
|
||||
@ -1,163 +1,21 @@
|
||||
/* 1) */
|
||||
@font-face {
|
||||
font-family: 'NanumSquareRound';
|
||||
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_two@1.0/NanumSquareRound.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
body {
|
||||
font-family: 'NanumSquareRound', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 2) */
|
||||
/* @font-face {
|
||||
font-family: 'Pretendard-Regular';
|
||||
src: url('https://cdn.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-Regular.woff') format('woff');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
body {
|
||||
font-family: 'Pretendard-Regular', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
} */
|
||||
|
||||
/* 3) */
|
||||
/* @font-face {
|
||||
font-family: 'NEXON Lv1 Gothic OTF';
|
||||
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_20-04@2.1/NEXON Lv1 Gothic OTF.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
body {
|
||||
font-family: 'NEXON Lv1 Gothic OTF', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
} */
|
||||
|
||||
/* 4) */
|
||||
/* @font-face {
|
||||
font-family: 'SUITE-Regular';
|
||||
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_2304-2@1.0/SUITE-Regular.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
body {
|
||||
font-family: 'SUITE-Regular', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
} */
|
||||
|
||||
/* 5) */
|
||||
/* @font-face {
|
||||
font-family: 'GoyangIlsan';
|
||||
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_one@1.0/GoyangIlsan.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
body {
|
||||
font-family: 'GoyangIlsan', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
} */
|
||||
|
||||
/* 6) */
|
||||
/* @font-face {
|
||||
font-family: 'GowunDodum-Regular';
|
||||
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_2108@1.1/GowunDodum-Regular.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
body {
|
||||
font-family: 'GowunDodum-Regular', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
} */
|
||||
|
||||
/* 7) */
|
||||
/* @font-face {
|
||||
font-family: 'EASTARJET-Medium';
|
||||
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_231029@1.1/EASTARJET-Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
}
|
||||
body {
|
||||
font-family: 'EASTARJET-Medium', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
} */
|
||||
|
||||
/* 8) */
|
||||
/* @font-face {
|
||||
font-family: 'HakgyoansimChulseokbuTTF-B';
|
||||
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/2408-5@1.0/HakgyoansimChulseokbuTTF-B.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
}
|
||||
body {
|
||||
font-family: 'HakgyoansimChulseokbuTTF-B', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
} */
|
||||
|
||||
/* 9) */
|
||||
/* @font-face {
|
||||
font-family: 'GongGothicMedium';
|
||||
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_20-10@1.0/GongGothicMedium.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
body {
|
||||
font-family: 'GongGothicMedium', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
} */
|
||||
|
||||
|
||||
/* @font-face {
|
||||
font-family: 'MangoDdobak-B';
|
||||
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/2405-3@1.1/MangoDdobak-B.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
}
|
||||
body {
|
||||
font-family: 'MangoDdobak-B', sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
} */
|
||||
|
||||
/* 나눔고딕 */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Nanum+Gothic&display=swap');
|
||||
|
||||
/* D2Coding 폰트 */
|
||||
@font-face {
|
||||
font-family: 'D2Coding';
|
||||
src: url('/font/D2Coding-Ver1.3.2-20180524-all.ttc') format('truetype');
|
||||
src: url('/font/D2Coding-Ver1.3.2-20180524-all.ttc') format('ttc');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* Consolas 폰트 */
|
||||
@font-face {
|
||||
font-family: 'Consolas';
|
||||
src: url('/font/Consolas.woff') format('font-woff');
|
||||
src: url('/font/Consolas.woff') format('woff');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* 툴바에서 선택 가능한 폰트 */
|
||||
/* 툴바에서 선택 가능한 폰트 및 크기 스타일 */
|
||||
.ql-font-nanum-gothic {
|
||||
font-family: 'Nanum Gothic', sans-serif;
|
||||
}
|
||||
@ -175,10 +33,24 @@ body {
|
||||
}
|
||||
|
||||
/* 폰트 크기 스타일 */
|
||||
.ql-size-12px { font-size: 12px; }
|
||||
.ql-size-14px { font-size: 14px; }
|
||||
.ql-size-16px { font-size: 16px; }
|
||||
.ql-size-18px { font-size: 18px; }
|
||||
.ql-size-24px { font-size: 24px; }
|
||||
.ql-size-32px { font-size: 32px; }
|
||||
.ql-size-48px { font-size: 48px; }
|
||||
.ql-size-12px {
|
||||
font-size: 12px;
|
||||
}
|
||||
.ql-size-14px {
|
||||
font-size: 14px;
|
||||
}
|
||||
.ql-size-16px {
|
||||
font-size: 16px;
|
||||
}
|
||||
.ql-size-18px {
|
||||
font-size: 18px;
|
||||
}
|
||||
.ql-size-24px {
|
||||
font-size: 24px;
|
||||
}
|
||||
.ql-size-32px {
|
||||
font-size: 32px;
|
||||
}
|
||||
.ql-size-48px {
|
||||
font-size: 48px;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 229 KiB |
BIN
public/img/icons/illustrations/page-misc-error-light.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 10 KiB |
@ -2,116 +2,116 @@
|
||||
* Main
|
||||
*/
|
||||
|
||||
(function () {
|
||||
// Initialize menu
|
||||
//-----------------
|
||||
let menu, animate;
|
||||
'use strict'
|
||||
|
||||
let layoutMenuEl = document.querySelectorAll('#layout-menu');
|
||||
layoutMenuEl.forEach(function (element) {
|
||||
menu = new Menu(element, {
|
||||
orientation: 'vertical',
|
||||
closeChildren: false,
|
||||
});
|
||||
// Change parameter to true if you want scroll animation
|
||||
window.Helpers.scrollToActive((animate = false));
|
||||
window.Helpers.mainMenu = menu;
|
||||
});
|
||||
let menu, animate
|
||||
;(function () {
|
||||
// Initialize menu
|
||||
//-----------------
|
||||
|
||||
// Initialize menu togglers and bind click on each
|
||||
let menuToggler = document.querySelectorAll('.layout-menu-toggle');
|
||||
menuToggler.forEach(item => {
|
||||
item.addEventListener('click', event => {
|
||||
event.preventDefault();
|
||||
window.Helpers.toggleCollapsed();
|
||||
});
|
||||
});
|
||||
let layoutMenuEl = document.querySelectorAll('#layout-menu')
|
||||
layoutMenuEl.forEach(function (element) {
|
||||
menu = new Menu(element, {
|
||||
orientation: 'vertical',
|
||||
closeChildren: false,
|
||||
})
|
||||
// Change parameter to true if you want scroll animation
|
||||
window.Helpers.scrollToActive((animate = false))
|
||||
window.Helpers.mainMenu = menu
|
||||
})
|
||||
|
||||
// Display menu toggle (layout-menu-toggle) on hover with delay
|
||||
let delay = function (elem, callback) {
|
||||
let timeout = null;
|
||||
elem.onmouseenter = function () {
|
||||
// Set timeout to be a timer which will invoke callback after 300ms (not for small screen)
|
||||
if (!Helpers.isSmallScreen()) {
|
||||
timeout = setTimeout(callback, 300);
|
||||
} else {
|
||||
timeout = setTimeout(callback, 0);
|
||||
}
|
||||
};
|
||||
// Initialize menu togglers and bind click on each
|
||||
let menuToggler = document.querySelectorAll('.layout-menu-toggle')
|
||||
menuToggler.forEach((item) => {
|
||||
item.addEventListener('click', (event) => {
|
||||
event.preventDefault()
|
||||
window.Helpers.toggleCollapsed()
|
||||
})
|
||||
})
|
||||
|
||||
elem.onmouseleave = function () {
|
||||
// Clear any timers set to timeout
|
||||
document.querySelector('.layout-menu-toggle').classList.remove('d-block');
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
};
|
||||
if (document.getElementById('layout-menu')) {
|
||||
delay(document.getElementById('layout-menu'), function () {
|
||||
// not for small screen
|
||||
if (!Helpers.isSmallScreen()) {
|
||||
document.querySelector('.layout-menu-toggle').classList.add('d-block');
|
||||
}
|
||||
});
|
||||
// Display menu toggle (layout-menu-toggle) on hover with delay
|
||||
let delay = function (elem, callback) {
|
||||
let timeout = null
|
||||
elem.onmouseenter = function () {
|
||||
// Set timeout to be a timer which will invoke callback after 300ms (not for small screen)
|
||||
if (!Helpers.isSmallScreen()) {
|
||||
timeout = setTimeout(callback, 300)
|
||||
} else {
|
||||
timeout = setTimeout(callback, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Display in main menu when menu scrolls
|
||||
let menuInnerContainer = document.getElementsByClassName('menu-inner'),
|
||||
menuInnerShadow = document.getElementsByClassName('menu-inner-shadow')[0];
|
||||
if (menuInnerContainer.length > 0 && menuInnerShadow) {
|
||||
menuInnerContainer[0].addEventListener('ps-scroll-y', function () {
|
||||
if (this.querySelector('.ps__thumb-y').offsetTop) {
|
||||
menuInnerShadow.style.display = 'block';
|
||||
} else {
|
||||
menuInnerShadow.style.display = 'none';
|
||||
}
|
||||
});
|
||||
elem.onmouseleave = function () {
|
||||
// Clear any timers set to timeout
|
||||
document.querySelector('.layout-menu-toggle').classList.remove('d-block')
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
if (document.getElementById('layout-menu')) {
|
||||
delay(document.getElementById('layout-menu'), function () {
|
||||
// not for small screen
|
||||
if (!Helpers.isSmallScreen()) {
|
||||
document.querySelector('.layout-menu-toggle').classList.add('d-block')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Init helpers & misc
|
||||
// --------------------
|
||||
// Display in main menu when menu scrolls
|
||||
let menuInnerContainer = document.getElementsByClassName('menu-inner'),
|
||||
menuInnerShadow = document.getElementsByClassName('menu-inner-shadow')[0]
|
||||
if (menuInnerContainer.length > 0 && menuInnerShadow) {
|
||||
menuInnerContainer[0].addEventListener('ps-scroll-y', function () {
|
||||
if (this.querySelector('.ps__thumb-y').offsetTop) {
|
||||
menuInnerShadow.style.display = 'block'
|
||||
} else {
|
||||
menuInnerShadow.style.display = 'none'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Init BS Tooltip
|
||||
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
|
||||
tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl);
|
||||
});
|
||||
// Init helpers & misc
|
||||
// --------------------
|
||||
|
||||
// Accordion active class
|
||||
const accordionActiveFunction = function (e) {
|
||||
if (e.type == 'show.bs.collapse' || e.type == 'show.bs.collapse') {
|
||||
e.target.closest('.accordion-item').classList.add('active');
|
||||
} else {
|
||||
e.target.closest('.accordion-item').classList.remove('active');
|
||||
}
|
||||
};
|
||||
// Init BS Tooltip
|
||||
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'))
|
||||
tooltipTriggerList.map(function (tooltipTriggerEl) {
|
||||
return new bootstrap.Tooltip(tooltipTriggerEl)
|
||||
})
|
||||
|
||||
const accordionTriggerList = [].slice.call(document.querySelectorAll('.accordion'));
|
||||
const accordionList = accordionTriggerList.map(function (accordionTriggerEl) {
|
||||
accordionTriggerEl.addEventListener('show.bs.collapse', accordionActiveFunction);
|
||||
accordionTriggerEl.addEventListener('hide.bs.collapse', accordionActiveFunction);
|
||||
});
|
||||
|
||||
// Auto update layout based on screen size
|
||||
window.Helpers.setAutoUpdate(true);
|
||||
|
||||
// Toggle Password Visibility
|
||||
window.Helpers.initPasswordToggle();
|
||||
|
||||
// Speech To Text
|
||||
window.Helpers.initSpeechToText();
|
||||
|
||||
// Manage menu expanded/collapsed with templateCustomizer & local storage
|
||||
//------------------------------------------------------------------
|
||||
|
||||
// If current layout is horizontal OR current window screen is small (overlay menu) than return from here
|
||||
if (window.Helpers.isSmallScreen()) {
|
||||
return;
|
||||
// Accordion active class
|
||||
const accordionActiveFunction = function (e) {
|
||||
if (e.type == 'show.bs.collapse' || e.type == 'show.bs.collapse') {
|
||||
e.target.closest('.accordion-item').classList.add('active')
|
||||
} else {
|
||||
e.target.closest('.accordion-item').classList.remove('active')
|
||||
}
|
||||
}
|
||||
|
||||
// If current layout is vertical and current window screen is > small
|
||||
const accordionTriggerList = [].slice.call(document.querySelectorAll('.accordion'))
|
||||
const accordionList = accordionTriggerList.map(function (accordionTriggerEl) {
|
||||
accordionTriggerEl.addEventListener('show.bs.collapse', accordionActiveFunction)
|
||||
accordionTriggerEl.addEventListener('hide.bs.collapse', accordionActiveFunction)
|
||||
})
|
||||
|
||||
// Auto update menu collapsed/expanded based on the themeConfig
|
||||
// 250304 pc 화면에서 메뉴바 고정을 위해 false 처리
|
||||
window.Helpers.setCollapsed(false, false);
|
||||
//window.Helpers.setCollapsed(true, false);
|
||||
})();
|
||||
// Auto update layout based on screen size
|
||||
window.Helpers.setAutoUpdate(true)
|
||||
|
||||
// Toggle Password Visibility
|
||||
window.Helpers.initPasswordToggle()
|
||||
|
||||
// Speech To Text
|
||||
window.Helpers.initSpeechToText()
|
||||
|
||||
// Manage menu expanded/collapsed with templateCustomizer & local storage
|
||||
//------------------------------------------------------------------
|
||||
|
||||
// If current layout is horizontal OR current window screen is small (overlay menu) than return from here
|
||||
if (window.Helpers.isSmallScreen()) {
|
||||
return
|
||||
}
|
||||
|
||||
// If current layout is vertical and current window screen is > small
|
||||
|
||||
// Auto update menu collapsed/expanded based on the themeConfig
|
||||
window.Helpers.setCollapsed(true, false)
|
||||
})()
|
||||
|
||||
8
public/vendor/css/core.css
vendored
@ -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 {
|
||||
|
||||
2
public/vendor/css/rtl/core.css
vendored
2
public/vendor/css/rtl/theme-default.css
vendored
2
public/vendor/fonts/boxicons.css
vendored
6
public/vendor/fonts/fontawesome.css
vendored
22
src/App.vue
@ -1,26 +1,12 @@
|
||||
<template>
|
||||
<link rel="stylesheet" href="/css/font.css">
|
||||
<component :is="layout">
|
||||
<normal-layout>
|
||||
<template #content>
|
||||
<LoadingSpinner :isLoading="loadingStore.isLoading" />
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
</component>
|
||||
<ToastModal />
|
||||
</normal-layout>
|
||||
</template>
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
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(() => {
|
||||
return route.meta.layout === 'NoLayout' ? NoLayout : NormalLayout;
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
</style>
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import axios from 'axios';
|
||||
import router from '@/router';
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
import { useLoadingStore } from '@s/loadingStore';
|
||||
import axios from "axios";
|
||||
import router from "@/router/index";
|
||||
|
||||
const $api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
timeout: 300000,
|
||||
withCredentials: true,
|
||||
});
|
||||
withCredentials : true
|
||||
})
|
||||
|
||||
/**
|
||||
* Default Content-Type : json
|
||||
@ -15,8 +13,6 @@ const $api = axios.create({
|
||||
*/
|
||||
$api.interceptors.request.use(
|
||||
function (config) {
|
||||
const loadingStore = useLoadingStore();
|
||||
loadingStore.startLoading();
|
||||
|
||||
let contentType = 'application/json';
|
||||
|
||||
@ -26,98 +22,21 @@ $api.interceptors.request.use(
|
||||
config.headers['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
return config;
|
||||
},
|
||||
function (error) {
|
||||
const loadingStore = useLoadingStore();
|
||||
loadingStore.stopLoading();
|
||||
}, function (error) {
|
||||
// 요청 오류가 있는 작업 수행
|
||||
return Promise.reject(error);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// 응답 인터셉터 추가하기
|
||||
$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');
|
||||
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');
|
||||
}
|
||||
|
||||
}, function (error) {
|
||||
// 2xx 외의 범위에 있는 상태 코드는 이 함수를 트리거 합니다.
|
||||
// 응답 오류가 있는 작업 수행
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
});
|
||||
export default $api;
|
||||
|
||||
@ -1,221 +0,0 @@
|
||||
/*
|
||||
작성자 : 공현지
|
||||
작성일 : 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');
|
||||
*/
|
||||
const common = {
|
||||
// JSON 문자열로 Delta 타입을 변환
|
||||
contentToHtml(content) {
|
||||
try {
|
||||
if (content.startsWith('{') || content.startsWith('[')) {
|
||||
// Delta 형식으로 변환
|
||||
const delta = JSON.parse(content);
|
||||
const quill = new Quill(document.createElement('div'));
|
||||
quill.setContents(delta);
|
||||
return quill.root.innerHTML; // HTML 반환
|
||||
}
|
||||
return content; // 이미 HTML일 경우 그대로 반환
|
||||
} catch (error) {
|
||||
console.error('콘텐츠 변환 오류:', error);
|
||||
return content; // 오류 발생 시 원본 반환
|
||||
}
|
||||
},
|
||||
// Delta 타입을 JSON 문자열로 변환
|
||||
deltaAsJson(content) {
|
||||
if (content && content.ops) {
|
||||
return JSON.stringify(content.ops); // Delta 객체에서 ops 속성만 JSON 문자열로 변환
|
||||
}
|
||||
console.error('잘못된 Delta 객체:', content);
|
||||
return null; // Delta 객체가 아니거나 ops가 없을 경우 null 반환
|
||||
},
|
||||
|
||||
/**
|
||||
* Date 타입 문자열 포멧팅
|
||||
*
|
||||
* @param {string} dateStr
|
||||
* @return
|
||||
* 1. Date type 인 경우 예시 '2025-02-24 12:02'
|
||||
* 2. Date type 이 아닌 경우 입력값 리턴
|
||||
*
|
||||
*/
|
||||
dateFormatter(dateStr, type = null) {
|
||||
const date = new Date(dateStr);
|
||||
const dateCheck = date.getTime();
|
||||
|
||||
if (isNaN(dateCheck)) {
|
||||
return dateStr;
|
||||
} else {
|
||||
const { year, month, day, hours, minutes } = this.formatDateTime(date);
|
||||
let callback = '';
|
||||
|
||||
if (type == 'YMD') {
|
||||
callback = `${year}-${month}-${day}`;
|
||||
} else if (type == 'MD') {
|
||||
callback = `${month}-${day}`;
|
||||
} else if (type == 'T') {
|
||||
callback = `${hours}:${minutes}`;
|
||||
} else {
|
||||
callback = `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
return callback;
|
||||
}
|
||||
},
|
||||
|
||||
formatDateTime(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
const dateCheck = date.getTime();
|
||||
if (isNaN(dateCheck)) return dateStr;
|
||||
|
||||
const zeroFormat = num => (num < 10 ? `0${num}` : num);
|
||||
return {
|
||||
year: date.getFullYear(),
|
||||
month: zeroFormat(date.getMonth() + 1),
|
||||
day: zeroFormat(date.getDate()),
|
||||
hours: zeroFormat(date.getHours()),
|
||||
minutes: zeroFormat(date.getMinutes()),
|
||||
seconds: zeroFormat(date.getSeconds()),
|
||||
};
|
||||
},
|
||||
|
||||
// 오늘 날짜시간 조회
|
||||
getToday() {
|
||||
const date = new Date();
|
||||
return {
|
||||
year: date.getFullYear(),
|
||||
month: date.getMonth() + 1,
|
||||
day: date.getDate(),
|
||||
hours: date.getHours(),
|
||||
minutes: date.getMinutes(),
|
||||
seconds: date.getSeconds(),
|
||||
};
|
||||
},
|
||||
|
||||
// 해당 날짜가 오늘인지 확인
|
||||
isToday(dateStr) {
|
||||
const date = new Date(dateStr);
|
||||
const dateCheck = date.getTime();
|
||||
if (isNaN(dateCheck)) return '날짜 타입 에러';
|
||||
|
||||
const today = new Date();
|
||||
return date.toDateString() === today.toDateString();
|
||||
},
|
||||
|
||||
// 해당 월, 일에 맞는 목록 필터링
|
||||
filterTargetByDate(target, key, month, day) {
|
||||
if (!Array.isArray(target) || target.length === 0) return [];
|
||||
|
||||
return [...target].filter(item => {
|
||||
if (!item[key]) return false;
|
||||
|
||||
const date = new Date(item[key]);
|
||||
const MatchingMonth = date.getMonth() + 1 === parseInt(month, 10);
|
||||
const MatchingDay = date.getDate() === parseInt(day, 10);
|
||||
|
||||
return MatchingMonth && MatchingDay;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 빈값 확인
|
||||
*
|
||||
* @param {} obj
|
||||
* @returns
|
||||
*/
|
||||
isNotEmpty(obj) {
|
||||
if (obj === null || obj === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof obj === 'string' && obj.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((Array.isArray(obj) || obj === Object(obj)) && Object.keys(obj).length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
|
||||
/**
|
||||
* 에디터에 내용이 있는지 확인
|
||||
*
|
||||
* @param { Quill } content
|
||||
* @returns true: 값 없음, false: 값 있음
|
||||
*/
|
||||
isNotValidContent(content) {
|
||||
if (!content.value?.ops?.length) return true;
|
||||
|
||||
// 이미지 포함 여부 확인
|
||||
const hasImage = content.value.ops.some(op => op.insert && typeof op.insert === 'object' && op.insert.image);
|
||||
// 텍스트 포함 여부 확인
|
||||
const hasText = content.value.ops.some(op => typeof op.insert === 'string' && op.insert.trim().length > 0);
|
||||
|
||||
// 텍스트 또는 이미지가 하나라도 있으면 유효한 내용
|
||||
return !(hasText || hasImage);
|
||||
},
|
||||
|
||||
/**
|
||||
* 빈 값 확인
|
||||
*
|
||||
* @param { ref } text ex) inNotValidInput(data.value);
|
||||
* @returns
|
||||
*/
|
||||
isNotValidInput(text) {
|
||||
return text.trim().length === 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* 프로필 이미지 반환
|
||||
*
|
||||
* @param { String } profileImg
|
||||
* @returns
|
||||
*/
|
||||
getProfileImage(profileImg, isAnonymous = false) {
|
||||
const defaultProfileImg = '/img/icons/icon.png'; // 기본 프로필 이미지 경로
|
||||
const anonymousImg = '/img/avatars/default-Profile.jpg'; // 익명 이미지
|
||||
let profileImgUrl = isAnonymous ? anonymousImg : defaultProfileImg;
|
||||
const UserProfile = `${import.meta.env.VITE_SERVER}upload/img/profile/${profileImg}`;
|
||||
|
||||
return !profileImg || profileImg === '' ? profileImgUrl : UserProfile;
|
||||
},
|
||||
|
||||
setDefaultImage(event, deafultImg = '/img/icons/icon.png') {
|
||||
return (event.target.src = deafultImg);
|
||||
},
|
||||
showImage(event) {
|
||||
return (event.target.style.visibility = 'visible');
|
||||
},
|
||||
|
||||
addHyphenToPhoneNumber(phoneNum) {
|
||||
const phoneNumber = phoneNum;
|
||||
const length = phoneNumber.length;
|
||||
|
||||
if (length >= 9) {
|
||||
return phoneNumber.replace(/[^0-9]/g, '').replace(/^(\d{2,3})(\d{3,4})(\d{4})$/, `$1-$2-$3`);
|
||||
} else {
|
||||
return phoneNum;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default {
|
||||
install(app) {
|
||||
app.config.globalProperties.$common = common;
|
||||
app.provide('common', common);
|
||||
},
|
||||
};
|
||||
@ -1,67 +0,0 @@
|
||||
/*
|
||||
작성자 : 박지윤
|
||||
작성일 : 2025-02-04
|
||||
수정자 :
|
||||
수정일 :
|
||||
설명 : 공통 api
|
||||
*/
|
||||
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 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 => ({
|
||||
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 () => {
|
||||
// 요청할 데이터가 옵션으로 전달 -> 그에 맞게 호출
|
||||
if (options.loadColor) {
|
||||
await CommonCode("user", "color", colorList, options.colorType);
|
||||
}
|
||||
if (options.loadMbti) await CommonCode("user", "mbti", mbtiList);
|
||||
if (options.loadPwhint) await CommonCode("user", "pwhint", pwhintList);
|
||||
if (options.loadYearCategory) await CommonCode("project", "yearCategory", yearCategory);
|
||||
if (options.loadCateList) await CommonCode("worddict", "getWordCategory", cateList);
|
||||
});
|
||||
|
||||
return {
|
||||
colorList,
|
||||
mbtiList,
|
||||
pwhintList,
|
||||
yearCategory,
|
||||
cateList,
|
||||
refreshColorList
|
||||
};
|
||||
};
|
||||
|
||||
export { refreshColorList };
|
||||
export default commonApi;
|
||||
@ -1,13 +0,0 @@
|
||||
/** 날짜 포맷1 (YYYY-MM-DD HH:MM) */
|
||||
export const formattedDate = (dateString) => {
|
||||
if (!dateString) return "날짜 없음";
|
||||
const dateObj = new Date(dateString);
|
||||
return `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')} ${String(dateObj.getHours()).padStart(2, '0')}:${String(dateObj.getMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
/** 날짜 포맷2 (YYYY-MM-DD) */
|
||||
export const formatDate = (dateString) => {
|
||||
if (!dateString) return "날짜 없음";
|
||||
const dateObj = new Date(dateString);
|
||||
return `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')}`;
|
||||
};
|
||||
60
src/components/board/BoardComentArea.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="card shadow-none border mt-6">
|
||||
<div class="card-body">
|
||||
<!-- 댓글 입력 섹션 -->
|
||||
<div class="d-flex justify-content-start align-items-top">
|
||||
<!-- 프로필섹션 -->
|
||||
<div class="avatar-wrapper">
|
||||
<div 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"
|
||||
placeholder="주제에 대한 생각을 자유롭게 댓글로 표현해 주세요. 여러분의 다양한 의견을 기다립니다."
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 옵션 및 버튼 섹션 -->
|
||||
<div class="d-flex justify-content-between flex-wrap mt-4">
|
||||
<div class="d-flex flex-wrap align-items-center">
|
||||
<!-- 익명 체크박스 -->
|
||||
<div 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 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 flex-grow-1"
|
||||
placeholder=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 답변 쓰기 버튼 -->
|
||||
<div class="ms-auto mt-3 mt-md-0">
|
||||
<button class="btn btn-primary">답변 쓰기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
const isCheck = ref(false);
|
||||
</script>
|
||||
@ -1,244 +1,66 @@
|
||||
<template>
|
||||
<div>
|
||||
<BoardProfile
|
||||
:unknown="comment.author === '익명'"
|
||||
:isCommentAuthor="isCommentAuthor"
|
||||
:boardId="comment.boardId"
|
||||
:profileName="displayName"
|
||||
: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 class="input-group">
|
||||
<input
|
||||
type="password"
|
||||
class="form-control"
|
||||
:value="password"
|
||||
autocomplete="new-password"
|
||||
maxlength="8"
|
||||
placeholder="비밀번호 입력"
|
||||
@input="filterInput"
|
||||
/>
|
||||
<button class="btn btn-primary" @click="logPasswordAndEmit">확인</button>
|
||||
</div>
|
||||
<span v-if="passwordCommentAlert" class="invalid-feedback d-block text-start">{{ passwordCommentAlert }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-6">
|
||||
<template v-if="comment.isEditTextarea">
|
||||
<textarea v-model="localEditedContent" class="form-control" maxLength="500"></textarea>
|
||||
<span v-if="editCommentAlert" class="invalid-feedback d-block text-start">{{ editCommentAlert }}</span>
|
||||
<div class="mt-2 d-flex justify-content-end">
|
||||
<SaveBtn class="btn btn-primary" @click="submitEdit" :isEnabled="disabled"></SaveBtn>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="m-0" style="white-space: pre-wrap">{{ comment.content }}</div>
|
||||
</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>
|
||||
|
||||
<BoardCommentArea v-if="isComment" :unknown="unknown" @submitComment="submitComment" :commnetId="comment.commentId" />
|
||||
|
||||
<slot name="reply"></slot>
|
||||
</div>
|
||||
<ul class="list-unstyled">
|
||||
<li>
|
||||
<BoardProfile profileName=곤데리 :showDetail="false" :author="true" />
|
||||
<div class="mt-2">저도 궁금합니다.</div>
|
||||
<PlusButton @click="toggleComment"/>
|
||||
<BoardComentArea v-if="comment" />
|
||||
<ul class="list-unstyled twoDepth">
|
||||
<li>
|
||||
<BoardProfile profileName=곤데리2 :showDetail="false" />
|
||||
<div class="mt-2">저도 궁금합니다.</div>
|
||||
<BoardComentArea v-if="comment" />
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<BoardProfile profileName=곤데리 :showDetail="false" />
|
||||
<div class="mt-2">저도 궁금합니다.</div>
|
||||
<PlusButton @click="toggleComment"/>
|
||||
<BoardComentArea v-if="comment" />
|
||||
</li>
|
||||
<li>
|
||||
<BoardProfile profileName=곤데리 :showDetail="false" />
|
||||
<div class="mt-2">저도 궁금합니다.</div>
|
||||
<PlusButton @click="toggleComment"/>
|
||||
<BoardComentArea v-if="comment" />
|
||||
</li>
|
||||
</ul>
|
||||
<Pagination/>
|
||||
</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';
|
||||
|
||||
const props = defineProps({
|
||||
comment: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
unknown: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
nickname: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isCommentAuthor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
import BoardProfile from './BoardProfile.vue';
|
||||
import BoardComentArea from './BoardComentArea.vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import Pagination from '../pagination/Pagination.vue';
|
||||
import PlusButton from '../button/PlusButton.vue';
|
||||
|
||||
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,
|
||||
});
|
||||
const comment = ref(false);
|
||||
|
||||
const isEditPushed = ref(false);
|
||||
const isDeletePushed = ref(false);
|
||||
const toggleComment = () => {
|
||||
comment.value = !comment.value
|
||||
};
|
||||
|
||||
const displayName = computed(() => {
|
||||
return props.nickname ? props.nickname : props.comment.author;
|
||||
});
|
||||
|
||||
// emits 정의
|
||||
const emit = defineEmits([
|
||||
'submitComment',
|
||||
'updateReaction',
|
||||
'editClick',
|
||||
'deleteClick',
|
||||
'submitPassword',
|
||||
'submitEdit',
|
||||
'cancelEdit',
|
||||
'update:password',
|
||||
'inputDetector',
|
||||
]);
|
||||
|
||||
const filterInput = event => {
|
||||
event.target.value = event.target.value.replace(/\s/g, ''); // 공백 제거
|
||||
emit('update:password', event.target.value);
|
||||
};
|
||||
|
||||
const localEditedContent = ref(props.comment.content);
|
||||
const isModifyContent = ref(props.comment.content);
|
||||
const disabled = ref(false);
|
||||
|
||||
// 댓글 입력 창 토글
|
||||
const isComment = ref(false);
|
||||
const toggleComment = () => {
|
||||
isComment.value = !isComment.value;
|
||||
};
|
||||
|
||||
// 부모 컴포넌트에 대댓글 추가 요청
|
||||
const submitComment = newComment => {
|
||||
emit('submitComment', { parentId: props.comment.commentId, ...newComment, LOCBRDTYP: newComment.LOCBRDTYP });
|
||||
isComment.value = false;
|
||||
};
|
||||
|
||||
// 좋아요, 싫어요
|
||||
const handleUpdateReaction = reactionData => {
|
||||
emit('updateReaction', {
|
||||
boardId: props.comment.boardId,
|
||||
commentId: props.comment.commentId || reactionData.commentId,
|
||||
...reactionData,
|
||||
});
|
||||
};
|
||||
|
||||
// 비밀번호 확인
|
||||
const logPasswordAndEmit = () => {
|
||||
emit('submitPassword', props.comment, props.password);
|
||||
};
|
||||
|
||||
const handleInject = inject('isBtnPushed');
|
||||
|
||||
// 수정, 삭제 버튼 활성화 상태값
|
||||
watch(
|
||||
() => handleInject.value,
|
||||
(newValue, oldValue) => {
|
||||
if (newValue) {
|
||||
if (newValue.target == props.comment.commentId) {
|
||||
isEditPushed.value = newValue.isEditPushed;
|
||||
isDeletePushed.value = newValue.isDeletePushed;
|
||||
} else {
|
||||
isEditPushed.value = false;
|
||||
isDeletePushed.value = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.comment.isEditTextarea,
|
||||
newVal => {
|
||||
if (newVal) {
|
||||
localEditedContent.value = props.comment.content;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// text 변화 감지하여 부모에게 전달
|
||||
watch(
|
||||
() => localEditedContent.value,
|
||||
newVal => {
|
||||
if (JSON.stringify(isModifyContent.value) == JSON.stringify(newVal)) {
|
||||
disabled.value = false;
|
||||
return;
|
||||
}
|
||||
disabled.value = true;
|
||||
emit('inputDetector');
|
||||
},
|
||||
);
|
||||
|
||||
// watch(() => props.comment.isDeleted, () => {
|
||||
// console.log("BoardComment - isDeleted 상태 변경됨:", newVal);
|
||||
|
||||
// if (newVal) {
|
||||
// localEditedContent.value = "댓글이 삭제되었습니다."; // UI 반영
|
||||
// props.comment.isEditTextarea = false;
|
||||
// }
|
||||
// });
|
||||
|
||||
// 수정버튼
|
||||
const submitEdit = () => {
|
||||
emit('submitEdit', props.comment, localEditedContent.value);
|
||||
};
|
||||
|
||||
const handleEditClick = () => {
|
||||
emit('editClick', props.comment);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.twoDepth {
|
||||
margin-top: 10px;
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.list-unstyled > li ~ li {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-text-primary {
|
||||
padding-left: 0;
|
||||
}
|
||||
.btn-text-primary:hover,
|
||||
.btn-text-primary:active,
|
||||
.btn-text-primary:focus {
|
||||
background-color: transparent
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,205 +0,0 @@
|
||||
<template>
|
||||
<div class="card shadow-none border mt-6">
|
||||
<div class="card-body">
|
||||
<!-- 댓글 입력 섹션 -->
|
||||
<div class="d-flex justify-content-start align-items-top">
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<!-- 비밀번호 입력 영역 -->
|
||||
<div class="position-relative">
|
||||
<input
|
||||
type="password"
|
||||
id="basic-default-password"
|
||||
class="form-control mb-1"
|
||||
autocomplete="new-password"
|
||||
v-model="password"
|
||||
placeholder="비밀번호"
|
||||
maxlength="8"
|
||||
@input="
|
||||
password = password.replace(/\s/g, '');
|
||||
clearAlert('password');
|
||||
"
|
||||
/>
|
||||
<!-- 비밀번호 경고 메시지 -->
|
||||
<div v-if="passwordAlert2" class="position-absolute text-danger small top-100 start-0">
|
||||
{{ passwordAlert2 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽: 답변 쓰기 버튼 -->
|
||||
<div class="ms-auto">
|
||||
<SaveBtn class="btn btn-primary" @click="handleCommentSubmit"></SaveBtn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineEmits, defineProps, watch, inject } from 'vue';
|
||||
import SaveBtn from '../button/SaveBtn.vue';
|
||||
|
||||
const props = defineProps({
|
||||
unknown: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
parentId: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
passwordAlert: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
commentAlert: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
maxLength: {
|
||||
type: Number,
|
||||
default: 500,
|
||||
},
|
||||
commnetId: {
|
||||
type: Number,
|
||||
},
|
||||
});
|
||||
|
||||
const noSpace = (e) => {
|
||||
if (e.key === ' ') e.preventDefault();
|
||||
};
|
||||
|
||||
const $common = inject('common');
|
||||
const comment = ref('');
|
||||
const password = ref('');
|
||||
const isCheck = ref(false);
|
||||
const textAlert = ref('');
|
||||
const nicknameAlert = ref('');
|
||||
const passwordAlert2 = ref('');
|
||||
const nickname = ref('');
|
||||
|
||||
const emit = defineEmits(['submitComment']);
|
||||
|
||||
// 입력 필드별 경고 메시지 초기화
|
||||
const clearAlert = field => {
|
||||
if (field === 'comment') textAlert.value = '';
|
||||
if (field === 'nickname') nicknameAlert.value = '';
|
||||
if (field === 'password') passwordAlert2.value = '';
|
||||
};
|
||||
|
||||
const handleCommentSubmit = () => {
|
||||
let isValid = true;
|
||||
|
||||
// 댓글 공백 체크
|
||||
if (!$common.isNotEmpty(comment.value)) {
|
||||
textAlert.value = '댓글을 입력해주세요.';
|
||||
isValid = false;
|
||||
} else {
|
||||
textAlert.value = '';
|
||||
}
|
||||
|
||||
// 익명 선택 시 닉네임 & 비밀번호 체크
|
||||
if (isCheck.value) {
|
||||
if (!$common.isNotEmpty(nickname.value)) {
|
||||
nicknameAlert.value = '닉네임을 입력해주세요.';
|
||||
isValid = false;
|
||||
} else {
|
||||
nicknameAlert.value = '';
|
||||
}
|
||||
|
||||
if (!$common.isNotEmpty(password.value)) {
|
||||
passwordAlert2.value = '비밀번호를 입력해주세요.';
|
||||
password.value = '';
|
||||
isValid = false;
|
||||
} else {
|
||||
passwordAlert2.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
// 모든 입력값이 유효할 경우만 제출
|
||||
if (!isValid) return;
|
||||
|
||||
// 댓글 제출
|
||||
emit('submitComment', {
|
||||
comment: comment.value,
|
||||
nickname: isCheck.value ? nickname.value : '',
|
||||
password: isCheck.value ? password.value : '',
|
||||
isCheck: isCheck.value,
|
||||
LOCBRDTYP: isCheck.value ? '300102' : null, // 익명일 경우 '300102' 설정
|
||||
});
|
||||
|
||||
// 제출 후 입력 필드 리셋
|
||||
resetCommentForm();
|
||||
};
|
||||
|
||||
// 비밀번호 & 닉네임 경고 초기화
|
||||
const pwd2AlertHandler = () => {
|
||||
if (!isCheck.value) {
|
||||
passwordAlert2.value = '';
|
||||
nicknameAlert.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// 입력 필드 리셋 함수 추가
|
||||
const resetCommentForm = () => {
|
||||
comment.value = '';
|
||||
password.value = '';
|
||||
nickname.value = '';
|
||||
isCheck.value = false;
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.passwordAlert,
|
||||
() => {
|
||||
if (!props.passwordAlert) {
|
||||
resetCommentForm();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
@ -1,176 +0,0 @@
|
||||
<template>
|
||||
<ul class="list-unstyled mt-10">
|
||||
<li v-for="comment in comments" :key="comment.commentId" class="mt-6 border-bottom pb-6">
|
||||
<BoardComment
|
||||
:unknown="unknown"
|
||||
:comment="comment"
|
||||
:isCommentAuthor="comment.isCommentAuthor"
|
||||
:isEditTextarea="comment.isEditTextarea"
|
||||
:isDeleted="isDeleted"
|
||||
:nickname="comment.nickname"
|
||||
:isCommentPassword="isCommentPassword"
|
||||
:passwordCommentAlert="passwordCommentAlert || ''"
|
||||
:currentPasswordCommentId="currentPasswordCommentId"
|
||||
:password="password"
|
||||
:editCommentAlert="editCommentAlert[comment.commentId]"
|
||||
:is-edit-pushed="comment.isEditPushed"
|
||||
:is-delete-pushed="comment.isDeletePushed"
|
||||
@editClick="handleEditClick"
|
||||
@deleteClick="handleDeleteClick"
|
||||
@submitPassword="submitPassword"
|
||||
@submitComment="submitComment"
|
||||
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent)"
|
||||
@cancelEdit="handleCancelEdit"
|
||||
@updateReaction="reactionData => handleUpdateReaction(reactionData, comment.commentId, comment.boardId)"
|
||||
@update:password="updatePassword"
|
||||
@inputDetector="$emit('inputDetector')"
|
||||
>
|
||||
<!-- 대댓글 -->
|
||||
<template #reply>
|
||||
<ul v-if="comment.children && comment.children.length" class="list-unstyled">
|
||||
<li v-for="(child, index) in comment.children" :key="child.commentId" class="mt-8 pt-6 ps-10 border-top">
|
||||
<BoardComment
|
||||
:comment="child"
|
||||
:unknown="child.author === '익명'"
|
||||
:isPlusButton="false"
|
||||
:isLike="true"
|
||||
:isCommentProfile="true"
|
||||
:nickname="child.nickname"
|
||||
:isCommentAuthor="child.isCommentAuthor"
|
||||
:isCommentPassword="isCommentPassword"
|
||||
:currentPasswordCommentId="currentPasswordCommentId"
|
||||
:passwordCommentAlert="passwordCommentAlert"
|
||||
:password="password"
|
||||
:editCommentAlert="editCommentAlert[child.commentId]"
|
||||
:is-edit-pushed="child.isEditPushed"
|
||||
:is-delete-pushed="child.isDeletePushed"
|
||||
@editClick="handleReplyEditClick"
|
||||
@deleteClick="$emit('deleteClick', child)"
|
||||
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent, child.commentId)"
|
||||
@cancelEdit="$emit('cancelEdit', child)"
|
||||
@submitComment="submitComment"
|
||||
@updateReaction="handleUpdateReaction"
|
||||
@submitPassword="$emit('submitPassword', child, password)"
|
||||
@update:password="$emit('update:password', $event)"
|
||||
@inputDetector="$emit('inputDetector')"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</BoardComment>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, watch } 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 emit = defineEmits([
|
||||
'submitComment',
|
||||
'updateReaction',
|
||||
'editClick',
|
||||
'deleteClick',
|
||||
'submitPassword',
|
||||
'clearPassword',
|
||||
'submitEdit',
|
||||
'update:password',
|
||||
'inputDetector',
|
||||
]);
|
||||
|
||||
const submitComment = replyData => {
|
||||
emit('submitComment', replyData);
|
||||
};
|
||||
|
||||
const handleUpdateReaction = (reactionData, commentId, boardId) => {
|
||||
const updatedReactionData = {
|
||||
...reactionData,
|
||||
commentId: commentId || reactionData.commentId,
|
||||
boardId: boardId || reactionData.boardId,
|
||||
};
|
||||
|
||||
emit('updateReaction', updatedReactionData);
|
||||
};
|
||||
|
||||
const submitPassword = (comment, password) => {
|
||||
emit('submitPassword', comment, password);
|
||||
};
|
||||
|
||||
const handleEditClick = comment => {
|
||||
if (comment.parentId) {
|
||||
emit('editClick', comment); // 대댓글
|
||||
} else {
|
||||
emit('editClick', comment); // 댓글
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmitEdit = (comment, editedContent) => {
|
||||
emit('submitEdit', comment, editedContent);
|
||||
};
|
||||
|
||||
const handleDeleteClick = comment => {
|
||||
if (comment.parentId) {
|
||||
emit('deleteClick', comment); // 대댓글 삭제
|
||||
} else {
|
||||
emit('deleteClick', comment); // 댓글 삭제
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelEdit = comment => {
|
||||
if (comment.parentId) {
|
||||
emit('cancelEdit', comment); // 대댓글 수정 취소
|
||||
} else {
|
||||
emit('cancelEdit', comment); // 댓글 수정 취소
|
||||
}
|
||||
};
|
||||
|
||||
const updatePassword = newPassword => {
|
||||
emit('update:password', newPassword);
|
||||
};
|
||||
|
||||
const handleReplyEditClick = comment => {
|
||||
emit('editClick', comment);
|
||||
};
|
||||
</script>
|
||||
@ -3,7 +3,7 @@
|
||||
<div class="d-flex justify-content-between align-items-center flex-wrap mb-6 gap-2">
|
||||
<!-- 제목 섹션 -->
|
||||
<div class="me-1">
|
||||
<h5 class="mb-0">{{ boardTitle }}adada</h5>
|
||||
<h5 class="mb-0">{{ boardTitle }}</h5>
|
||||
</div>
|
||||
<!-- 첨부파일 섹션 -->
|
||||
<div v-if="dropdownItems.length > 0" class="btn-group">
|
||||
@ -32,7 +32,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import BoardRecommendBtn from '../button/BoardRecommendBtn.vue';
|
||||
import BoardRecommendBtn from './BoardRecommendBtn.vue';
|
||||
|
||||
defineProps({
|
||||
boardTitle : {
|
||||
@ -51,10 +51,6 @@ defineProps({
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
attachments: {
|
||||
type: Array, // 첨부파일 배열 타입
|
||||
default: () => [], // 기본값 설정
|
||||
},
|
||||
});
|
||||
|
||||
</script>
|
||||
@ -63,4 +59,4 @@ defineProps({
|
||||
.min-250 {
|
||||
min-height: 250px !important;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@ -1,164 +1,95 @@
|
||||
<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 class="avatar me-2" v-if="unknown">
|
||||
<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>
|
||||
<span>2024.12.10 10:46</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>
|
||||
<i class="fa-regular fa-eye"></i> 1
|
||||
</span>
|
||||
<span>
|
||||
<i class="fa-regular fa-thumbs-up"></i> 1
|
||||
</span>
|
||||
<span>
|
||||
<i class="fa-regular fa-thumbs-down"></i> 1
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 버튼 영역 -->
|
||||
<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>
|
||||
<div class="ms-auto btn-area">
|
||||
<template v-if="showDetail">
|
||||
<EditButton />
|
||||
<DeleteButton />
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="float-end ms-1">
|
||||
<slot name="gobackBtn"></slot>
|
||||
</div>
|
||||
<template v-if="author">
|
||||
<button class="btn author btn-label-primary btn-icon">
|
||||
<i class='bx bx-edit-alt'></i>
|
||||
</button>
|
||||
<button class="btn author btn-label-primary btn-icon">
|
||||
<i class='bx bx-trash' ></i>
|
||||
</button>
|
||||
</template>
|
||||
<BoardRecommendBtn :likeClicked="true" :dislikeClicked="false"/>
|
||||
</template>
|
||||
|
||||
<!-- 좋아요, 싫어요 버튼 (댓글에서만 표시) -->
|
||||
<BoardRecommendBtn
|
||||
v-if="isLike && !isDeletedComment"
|
||||
:boardId="boardId"
|
||||
:comment="comment"
|
||||
:likeClicked="comment.likeClicked"
|
||||
:dislikeClicked="comment.dislikeClicked"
|
||||
@updateReaction="handleUpdateReaction"
|
||||
/>
|
||||
</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 DeleteButton from '../button/DeleteButton.vue';
|
||||
import EditButton from '../button/EditButton.vue';
|
||||
import BoardRecommendBtn from './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,
|
||||
},
|
||||
});
|
||||
defineProps({
|
||||
profileName : {
|
||||
type: String,
|
||||
default: '익명',
|
||||
},
|
||||
unknown : {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
showDetail : {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
author : {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['updateReaction', 'editClick', 'deleteClick']);
|
||||
const $common = inject('common');
|
||||
|
||||
const isDeletedComment = computed(() => {
|
||||
return props.comment?.content === '삭제된 댓글입니다' && props.comment?.updateAtRaw !== props.comment?.createdAtRaw;
|
||||
});
|
||||
|
||||
// 수정
|
||||
const editClick = () => {
|
||||
emit('editClick', { ...props.comment, unknown: props.unknown });
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const deleteClick = () => {
|
||||
emit('deleteClick', { ...props.comment, unknown: props.unknown });
|
||||
};
|
||||
|
||||
// 좋아요/싫어요 업데이트
|
||||
const handleUpdateReaction = reactionData => {
|
||||
emit('updateReaction', {
|
||||
boardId: props.boardId,
|
||||
commentId: props.comment?.commentId,
|
||||
...reactionData,
|
||||
});
|
||||
};
|
||||
|
||||
// 프로필 이미지 경로 설정
|
||||
const getProfileImage = profileImg => {
|
||||
return $common.getProfileImage(profileImg, props.unknown);
|
||||
};
|
||||
|
||||
const setDefaultImage = e => {
|
||||
return $common.setDefaultImage(e);
|
||||
};
|
||||
|
||||
const showImage = e => {
|
||||
return $common.showImage(e);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.profile-detail span ~ span {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.ms-auto button + button {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.btn.author {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
@media screen and (max-width:450px) {
|
||||
.btn-area {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn.author {
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
72
src/components/board/BoardRecommendBtn.vue
Normal file
@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<button class="btn btn-label-primary btn-icon" :class="likeClicked ? 'clicked' : '', bigBtn ? 'big' : '' ">
|
||||
<i class="fa-regular fa-thumbs-up"></i> <span class="num">1</span>
|
||||
</button>
|
||||
<button class="btn btn-label-danger btn-icon" :class="dislikeClicked ? 'clicked' : '', bigBtn ? 'big' : '' ">
|
||||
<i class="fa-regular fa-thumbs-down"></i> <span class="num">1</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
likeClicked : {
|
||||
type : Boolean,
|
||||
default : false,
|
||||
},
|
||||
dislikeClicked : {
|
||||
type : Boolean,
|
||||
default : false,
|
||||
},
|
||||
bigBtn : {
|
||||
type :Boolean,
|
||||
default : 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>
|
||||
@ -1,12 +0,0 @@
|
||||
<template>
|
||||
<button type="button" class="btn btn-info" @click="$emit('click')">
|
||||
<i class="bx bx-left-arrow-alt"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "BackButton",
|
||||
emits: ["click"],
|
||||
};
|
||||
</script>
|
||||
@ -1,89 +0,0 @@
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
comment: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
likeClicked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
dislikeClicked: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
bigBtn: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isRecommend: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
boardId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
commentId: {
|
||||
type: [Number, null],
|
||||
default: null,
|
||||
},
|
||||
likeCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
dislikeCount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const 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);
|
||||
|
||||
watch([() => props.likeClicked, () => props.dislikeClicked], ([likeNewval, dislikeNewval]) => {
|
||||
likeClicked.value = likeNewval;
|
||||
dislikeClicked.value = dislikeNewval;
|
||||
});
|
||||
|
||||
const handleLike = () => {
|
||||
const isLike = !likeClicked.value;
|
||||
const isDislike = false;
|
||||
|
||||
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
|
||||
likeClicked.value = isLike;
|
||||
dislikeClicked.value = false;
|
||||
};
|
||||
|
||||
const handleDislike = () => {
|
||||
const isDislike = !dislikeClicked.value;
|
||||
const isLike = false;
|
||||
|
||||
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
|
||||
dislikeClicked.value = isDislike;
|
||||
likeClicked.value = false;
|
||||
};
|
||||
</script>
|
||||
@ -1,14 +0,0 @@
|
||||
<template>
|
||||
<button class="btn btn-label-primary btn-icon" :class="{ active: props.isPushed }">
|
||||
<i class="bx bx-trash"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
isPushed: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
13
src/components/button/DeleteButton.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<button class="btn btn-label-primary btn-icon">
|
||||
<i class='bx bx-trash' ></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DeleteButton',
|
||||
methods: {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@ -1,38 +0,0 @@
|
||||
<template>
|
||||
<button class="btn btn-label-primary btn-icon" :class="{ active: props.isPushed }" @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 });
|
||||
</script>
|
||||
13
src/components/button/EditButton.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<button class="btn btn-label-primary btn-icon">
|
||||
<i class="bx bx-edit-alt"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'EditButton',
|
||||
methods: {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@ -1,72 +0,0 @@
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineEmits, ref, defineProps } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
isDisabled: Boolean
|
||||
});
|
||||
|
||||
const emit = defineEmits(["toggleHalfDay", "addVacationRequests", "resetHalfDay"]);
|
||||
const halfDayType = ref(null);
|
||||
|
||||
const toggleHalfDay = (type) => {
|
||||
halfDayType.value = halfDayType.value === type ? null : type;
|
||||
emit("toggleHalfDay", halfDayType.value);
|
||||
};
|
||||
|
||||
// 날짜 클릭 후 버튼 상태 자동 초기화
|
||||
const resetHalfDay = () => {
|
||||
halfDayType.value = null;
|
||||
emit("resetHalfDay");
|
||||
};
|
||||
|
||||
const addVacationRequests = () => {
|
||||
emit("addVacationRequests");
|
||||
};
|
||||
|
||||
defineExpose({ resetHalfDay });
|
||||
</script>
|
||||
@ -1,14 +0,0 @@
|
||||
<template>
|
||||
<button class="btn btn-label-primary btn-icon">
|
||||
<slot>
|
||||
<i class="icon-base bx bx-plus"></i>
|
||||
</slot>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PlusButton',
|
||||
methods: {},
|
||||
};
|
||||
</script>
|
||||
13
src/components/button/PlusButton.vue
Normal file
@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<button class="btn btn-label-primary btn-icon">
|
||||
<i class="icon-base bx bx-plus"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'PlusButton',
|
||||
methods: {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<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, // 기본적으로 활성화
|
||||
},
|
||||
},
|
||||
emits: ['click'],
|
||||
};
|
||||
</script>
|
||||
@ -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>
|
||||
@ -1,41 +0,0 @@
|
||||
<template>
|
||||
<button class="btn btn-label-primary btn-icon float-end" @click="toggleText">
|
||||
<i :class="buttonClass"></i>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineProps, defineExpose, watch } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
isToggleEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
const buttonClass = ref("bx bx-edit");
|
||||
|
||||
watch(
|
||||
() => props.isActive,
|
||||
newVal => {
|
||||
buttonClass.value = newVal ? 'bx bx-x' : 'bx bx-edit';
|
||||
},
|
||||
);
|
||||
|
||||
const toggleText = () => {
|
||||
if (props.isToggleEnabled) {
|
||||
buttonClass.value = buttonClass.value === "bx bx-edit" ? "bx bx-x" : "bx bx-edit";
|
||||
}
|
||||
};
|
||||
|
||||
const resetButton = () => {
|
||||
buttonClass.value = "bx bx-edit";
|
||||
};
|
||||
|
||||
defineExpose({ resetButton });
|
||||
</script>
|
||||
@ -67,7 +67,7 @@ import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import CenterModal from '@c/modal/CenterModal.vue';
|
||||
import { inject, onMounted, reactive, ref } from 'vue';
|
||||
import axios from '@api';
|
||||
import axios from 'axios';
|
||||
import { isEmpty } from '@/common/utils';
|
||||
import FormInput from '../input/FormInput.vue';
|
||||
import FlatPickr from 'vue-flatpickr-component';
|
||||
|
||||
@ -1,18 +0,0 @@
|
||||
// src/api/holiday.js
|
||||
import axios from "@api";
|
||||
|
||||
export async function fetchHolidays(year, month) {
|
||||
try {
|
||||
const response = await axios.get(`vacation/${year}/${month}`);
|
||||
const holidayEvents = response.data.map((holiday) => ({
|
||||
title: holiday.name,
|
||||
start: holiday.date, // "YYYY-MM-DD" 형식
|
||||
backgroundColor: "#ff6666",
|
||||
classNames: ["holiday-event"],
|
||||
}));
|
||||
return holidayEvents;
|
||||
} catch (error) {
|
||||
console.error("공휴일 정보를 불러오지 못했습니다.", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -1,65 +0,0 @@
|
||||
<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()
|
||||
}"
|
||||
@click="selectCategory(category.value)"
|
||||
>
|
||||
{{ category.label }}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, ref, watch } from 'vue';
|
||||
|
||||
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 selectCategory = (cate) => {
|
||||
selectedCategory.value = selectedCategory.value === cate ? null : cate;
|
||||
emit('update:data', selectedCategory.value);
|
||||
};
|
||||
|
||||
watch(() => props.selectedCategory, (newVal) => {
|
||||
selectedCategory.value = newVal;
|
||||
});
|
||||
|
||||
|
||||
</script>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -41,276 +41,148 @@
|
||||
|
||||
<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>
|
||||
<!-- Alert 메시지 표시 -->
|
||||
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">내용을 확인해주세요.</div>
|
||||
<div ref="editor"></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 } from 'vue';
|
||||
import $api from '@api';
|
||||
|
||||
const toastStore = useToastStore();
|
||||
const editor = ref(null);
|
||||
const font = ref('nanum-gothic');
|
||||
const fontSize = ref('16px');
|
||||
const emit = defineEmits(['update:data']);
|
||||
|
||||
const props = defineProps({
|
||||
isAlert: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
initialData: {
|
||||
type: [String, Object],
|
||||
default: () => null,
|
||||
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);
|
||||
|
||||
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', () => {
|
||||
emit('update:data', quillInstance.root.innerHTML);
|
||||
});
|
||||
|
||||
// 툴바에서 선택할 수 있는 폰트 크기 목록 설정
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// 이미지 업로드 및 삭제 감지 로직
|
||||
// 아직 서버에 실험 안해봄 ***********처리부탁***********
|
||||
let imageUrls = new Set();
|
||||
|
||||
quillInstance.getModule('toolbar').addHandler('image', () => {
|
||||
selectLocalImage();
|
||||
});
|
||||
|
||||
quillInstance.on('text-change', (delta, oldDelta, source) => {
|
||||
emit('update:data', quillInstance.root.innerHTML);
|
||||
delta.ops.forEach(op => {
|
||||
if (op.insert && typeof op.insert === 'object' && op.insert.image) {
|
||||
const imageUrl = op.insert.image;
|
||||
imageUrls.add(imageUrl);
|
||||
} else if (op.delete) {
|
||||
checkForDeletedImages();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
const range = quillInstance.getSelection();
|
||||
const base64Image = e.target.result;
|
||||
|
||||
try {
|
||||
const serverImageUrl = await uploadImageToServer(base64Image);
|
||||
quillInstance.insertEmbed(range.index, 'image', serverImageUrl);
|
||||
imageUrls.add(serverImageUrl);
|
||||
} catch (error) {
|
||||
console.error('이미지 업로드 중 오류 발생:', error);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
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);
|
||||
removeImageFromServer(url);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function uploadImageToServer(base64Image) {
|
||||
try {
|
||||
const response = await $api.post('/img/upload', {
|
||||
image: base64Image,
|
||||
});
|
||||
return response.data.url; // 서버에서 반환한 이미지 URL
|
||||
} catch (error) {
|
||||
console.error('서버 업로드 중 오류 발생:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeImageFromServer(imageUrl) {
|
||||
try {
|
||||
await $api.delete('/img/delete', {
|
||||
data: { url: imageUrl },
|
||||
});
|
||||
console.log(`서버에서 이미지 삭제: ${imageUrl}`);
|
||||
} catch (error) {
|
||||
console.error('서버 이미지 삭제 중 오류 발생:', error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import 'quill/dist/quill.snow.css';
|
||||
|
||||
.ql-editor {
|
||||
min-height: 300px;
|
||||
font-family: 'Nanum Gothic', sans-serif;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,153 +0,0 @@
|
||||
<template>
|
||||
<div class="mb-2 row">
|
||||
<div class="d-flex">
|
||||
<label :for="name" class="col-md-2 col-form-label">
|
||||
{{ title }}
|
||||
<span :class="isEssential ? 'link-danger' : 'd-none'">*</span>
|
||||
</label>
|
||||
<div class="align-content-center col-md-10 text-end ms-auto">
|
||||
<button type="button" class="btn btn-sm btn-primary" :class="isRow ? '' : 'ms-auto'" @click="openAddressSearch">주소찾기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="isRow ? 'col-md-10 ms-auto' : 'col-md-12'">
|
||||
<div class="d-flex mb-3">
|
||||
<input
|
||||
:id="name"
|
||||
class="form-control me-2 w-25"
|
||||
type="text"
|
||||
v-model="postcode"
|
||||
placeholder="우편번호"
|
||||
disabled="true"
|
||||
readonly
|
||||
/>
|
||||
|
||||
<input
|
||||
class="form-control"
|
||||
type="text"
|
||||
v-model="address"
|
||||
placeholder="기본주소"
|
||||
disabled="true"
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<input
|
||||
class="form-control"
|
||||
type="text"
|
||||
v-model="detailAddress"
|
||||
placeholder="상세주소"
|
||||
@input="updateDetailAddress"
|
||||
:maxLength="maxlength"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }}를 확인해주세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
const postcode = ref('');
|
||||
const address = ref('');
|
||||
const detailAddress = ref('');
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '라벨',
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: 'nameplz',
|
||||
required: true,
|
||||
},
|
||||
isEssential: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
maxlength: {
|
||||
type: Number,
|
||||
default: 30,
|
||||
required: false,
|
||||
},
|
||||
isRow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
isAlert: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
postcode: '',
|
||||
address: '',
|
||||
detailAddress: ''
|
||||
}),
|
||||
required: false
|
||||
}
|
||||
});
|
||||
|
||||
// watch 설정 수정
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (newValue) {
|
||||
postcode.value = newValue.postcode || '';
|
||||
address.value = newValue.address || '';
|
||||
detailAddress.value = newValue.detailAddress || '';
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const emits = defineEmits(['update:data', 'update:alert', 'update:modelValue']);
|
||||
|
||||
// 주소 검색 팝업 열기
|
||||
const openAddressSearch = () => {
|
||||
new window.daum.Postcode({
|
||||
oncomplete: (data) => {
|
||||
postcode.value = data.zonecode;
|
||||
address.value = data.address;
|
||||
|
||||
// 전체 주소 데이터 내보내기
|
||||
emitAddressData();
|
||||
|
||||
// 상세주소 입력 필드로 포커스 이동
|
||||
setTimeout(() => {
|
||||
const detailInput = document.querySelector('input[placeholder="상세주소"]');
|
||||
if (detailInput) detailInput.focus();
|
||||
}, 100);
|
||||
}
|
||||
}).open();
|
||||
};
|
||||
|
||||
// 상세주소 업데이트
|
||||
const updateDetailAddress = (event) => {
|
||||
detailAddress.value = event.target.value;
|
||||
emitAddressData();
|
||||
};
|
||||
|
||||
// 전체 주소 데이터 내보내기
|
||||
const emitAddressData = () => {
|
||||
const fullAddress = {
|
||||
postcode: postcode.value,
|
||||
address: address.value,
|
||||
detailAddress: detailAddress.value,
|
||||
};
|
||||
emits('update:data', fullAddress);
|
||||
emits('update:modelValue', fullAddress); // modelValue도 부모로 전달
|
||||
};
|
||||
|
||||
// isAlert를 false로 설정
|
||||
watch([postcode, address], ([newPostcode, newAddress]) => {
|
||||
if (newPostcode && newAddress) {
|
||||
emits('update:alert', false);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@ -1,15 +1,11 @@
|
||||
<template>
|
||||
<div class="mb-4 row">
|
||||
<label :for="inputId" class="col-md-2 col-form-label">{{ title }}</label>
|
||||
<label :for="name" class="col-md-2 col-form-label">{{ title }}</label>
|
||||
<div class="col-md-10">
|
||||
<label :for="inputId" class="btn btn-label-primary">파일 선택</label>
|
||||
<input
|
||||
class="form-control"
|
||||
type="file"
|
||||
style="display: none"
|
||||
:id="inputId"
|
||||
ref="fileInput"
|
||||
:key="autoIncrement"
|
||||
:id="name"
|
||||
@change="changeHandler"
|
||||
multiple
|
||||
/>
|
||||
@ -21,71 +17,72 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { fileMsg } from '@/common/msgEnum';
|
||||
import { ref ,computed} from 'vue';
|
||||
import { fileMsg } from '@/common/msgEnum';
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '라벨',
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: 'fileInput',
|
||||
required: true,
|
||||
},
|
||||
isAlert: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
//:key="autoIncrement" 동일한 파일을 첨부하고 비웠다 다시넣으면 안들어가는 현상 대비.
|
||||
// Props
|
||||
const prop = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '라벨',
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: 'nameplz',
|
||||
required: true,
|
||||
},
|
||||
isAlert: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
const inputId = computed(() => props.name || 'defaultFileInput');
|
||||
const emits = defineEmits(['update:data', 'update:isValid']);
|
||||
|
||||
const emits = defineEmits(['update:data', 'update:isValid']);
|
||||
const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const MAX_FILE_COUNT = 5; // 최대 파일 개수
|
||||
const ALLOWED_FILE_TYPES = ['image/jpeg', 'image/png', 'application/pdf']; // 허용된 파일 유형
|
||||
|
||||
const autoIncrement = ref(props.autoIncrement);
|
||||
const showError = ref(false);
|
||||
const fileMsgKey = ref(''); // 에러 메시지 키
|
||||
|
||||
const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const MAX_FILE_COUNT = 5; // 최대 파일 개수
|
||||
const ALLOWED_FILE_TYPES = []; // 모든 파일을 허용
|
||||
const changeHandler = (event) => {
|
||||
const files = Array.from(event.target.files);
|
||||
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
|
||||
const invalidFiles = files.filter(file => !ALLOWED_FILE_TYPES.includes(file.type));
|
||||
|
||||
const showError = ref(false);
|
||||
const fileMsgKey = ref(''); // 에러 메시지 키
|
||||
// 파일 검증 로직
|
||||
if (totalSize > MAX_TOTAL_SIZE) {
|
||||
showError.value = true;
|
||||
fileMsgKey.value = 'FileMaxSizeMsg';
|
||||
emits('update:data', []);
|
||||
emits('update:isValid', false);
|
||||
} else if (files.length > MAX_FILE_COUNT) {
|
||||
showError.value = true;
|
||||
fileMsgKey.value = 'FileMaxLengthMsg';
|
||||
emits('update:data', []);
|
||||
emits('update:isValid', false);
|
||||
} else if (invalidFiles.length > 0) {
|
||||
showError.value = true;
|
||||
fileMsgKey.value = 'FileNotTypeMsg';
|
||||
emits('update:data', []);
|
||||
emits('update:isValid', false);
|
||||
} else {
|
||||
showError.value = false;
|
||||
fileMsgKey.value = '';
|
||||
emits('update:data', files);
|
||||
emits('update:isValid', true);
|
||||
}
|
||||
};
|
||||
|
||||
const changeHandler = event => {
|
||||
const files = Array.from(event.target.files);
|
||||
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
|
||||
|
||||
// ALLOWED_FILE_TYPES가 비어있으면 모든 파일 허용
|
||||
const invalidFiles = ALLOWED_FILE_TYPES.length > 0 ? files.filter(file => !ALLOWED_FILE_TYPES.includes(file.type)) : [];
|
||||
|
||||
if (totalSize > MAX_TOTAL_SIZE) {
|
||||
showError.value = true;
|
||||
fileMsgKey.value = 'FileMaxSizeMsg';
|
||||
emits('update:data', []);
|
||||
emits('update:isValid', false);
|
||||
} else if (files.length > MAX_FILE_COUNT) {
|
||||
showError.value = true;
|
||||
fileMsgKey.value = 'FileMaxLengthMsg';
|
||||
emits('update:data', []);
|
||||
emits('update:isValid', false);
|
||||
} else if (invalidFiles.length > 0) {
|
||||
showError.value = true;
|
||||
fileMsgKey.value = 'FileNotTypeMsg';
|
||||
emits('update:data', []);
|
||||
emits('update:isValid', false);
|
||||
} else {
|
||||
showError.value = false;
|
||||
fileMsgKey.value = '';
|
||||
emits('update:data', files);
|
||||
emits('update:isValid', true);
|
||||
}
|
||||
};
|
||||
|
||||
const errorMessage = computed(() => fileMsg[fileMsgKey.value] || '');
|
||||
// Computed: 에러 메시지 가져오기
|
||||
const errorMessage = computed(() => (fileMsg[fileMsgKey.value] || ''));
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.text-danger {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,120 +1,76 @@
|
||||
<template>
|
||||
<div class="mb-2 row">
|
||||
<label :for="name" class="col-md-2 col-form-label" :class="isLabel ? 'd-block' : 'd-none'">
|
||||
<div class="mb-4 row">
|
||||
<label :for="name" class="col-md-2 col-form-label">
|
||||
{{ title }}
|
||||
<span v-if="isEssential" class="text-danger">*</span>
|
||||
<span :class="isEssential ? 'text-red' : 'none'">*</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>
|
||||
</div>
|
||||
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }}을 확인해주세요.</div>
|
||||
<div class="invalid-feedback" :class="isCateAlert ? 'd-block' : ''">카테고리 중복입니다.</div>
|
||||
<input :id="name"
|
||||
class="form-control"
|
||||
:type="type"
|
||||
@input="updateInput"
|
||||
:value="value"
|
||||
:maxLength="maxlength"
|
||||
:placeholder="title" />
|
||||
<div class="invalid-feedback" :class="isAlert ? 'display-block' : ''">{{ title }}을 확인해주세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
import { ref } 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,
|
||||
}
|
||||
});
|
||||
const prop = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '라벨',
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: 'nameplz',
|
||||
required: true,
|
||||
},
|
||||
isEssential: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
required: false,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
require: false,
|
||||
},
|
||||
maxlength: {
|
||||
type: Number,
|
||||
default: 30,
|
||||
required: false,
|
||||
},
|
||||
isAlert : {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
}
|
||||
});
|
||||
|
||||
// Emits 정의
|
||||
const emits = defineEmits(['update:modelValue', 'focusout', 'update:alert']);
|
||||
const emits = defineEmits(['update:data'])
|
||||
|
||||
// 로컬 상태로 사용하기 위한 `inputValue`
|
||||
const inputValue = ref(props.modelValue);
|
||||
|
||||
// 부모로 데이터 업데이트
|
||||
watch(inputValue, newValue => {
|
||||
emits('update:modelValue', newValue);
|
||||
});
|
||||
|
||||
// 초기값 동기화
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
newValue => {
|
||||
if (inputValue.value !== newValue) {
|
||||
inputValue.value = newValue;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const handleInput = event => {
|
||||
const newValue = event.target.value.slice(0, props.maxlength);
|
||||
|
||||
if (newValue.trim() !== '') {
|
||||
emits('update:alert', false);
|
||||
}
|
||||
};
|
||||
const updateInput = function (event) {
|
||||
//Type Number 일때 maxlength 적용 안됨 방지
|
||||
if (event.target.value.length > prop.maxlength) {
|
||||
event.target.value = event.target.value.slice(0, prop.maxlength);
|
||||
}
|
||||
emits('update:data', event.target.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.none {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,92 +0,0 @@
|
||||
<template>
|
||||
<div class="input-group">
|
||||
<input
|
||||
:id="name"
|
||||
class="form-control"
|
||||
:type="type"
|
||||
v-model="inputValue"
|
||||
:maxLength="maxlength"
|
||||
:placeholder="isLabel ? '' : title"
|
||||
/>
|
||||
<button class="btn btn-primary" type="button" @click="handleSubmit">확인</button>
|
||||
</div>
|
||||
<div class="invalid-feedback" :class="isAlert ? 'display-block' : ''">
|
||||
{{ title }}을 확인해주세요.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
// Props 정의
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '라벨',
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: 'nameplz',
|
||||
},
|
||||
isEssential: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
maxlength: {
|
||||
type: Number,
|
||||
default: 30,
|
||||
},
|
||||
isAlert: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLabel: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Emits 정의
|
||||
const emits = defineEmits(['update:modelValue', 'submit']);
|
||||
|
||||
// 로컬 상태로 사용하기 위한 `inputValue`
|
||||
const inputValue = ref(props.modelValue);
|
||||
|
||||
// 부모로 데이터 업데이트
|
||||
watch(inputValue, (newValue) => {
|
||||
emits('update:modelValue', newValue);
|
||||
});
|
||||
|
||||
// 초기값 동기화
|
||||
watch(() => props.modelValue, (newValue) => {
|
||||
if (inputValue.value !== newValue) {
|
||||
inputValue.value = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
// 버튼 클릭 시 부모로 submit 이벤트 전달
|
||||
const handleSubmit = () => {
|
||||
emits('submit', inputValue.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.invalid-feedback {
|
||||
display: none;
|
||||
color: red;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.display-block {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@ -1,45 +1,20 @@
|
||||
<template>
|
||||
<div class="mb-2" :class="isRow ? 'row' : ''">
|
||||
<label :for="name" class="col-md-2 col-form-label" :class="isLabel ? 'd-block' : 'd-none'">
|
||||
<div class="mb-4 row">
|
||||
<label for="input-ss" class="col-md-2 col-form-label">
|
||||
{{ title }}
|
||||
<span v-if="isEssential" class="link-danger">*</span>
|
||||
<span :class="isEssential ? 'text-red' : 'none'">*</span>
|
||||
</label>
|
||||
<div :class="isRow ? 'col-md-10' : 'col-md-12'">
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<select v-if="isColor && (!data || data.length === 0)" class="form-select" disabled>
|
||||
<option>사용가능한 컬러가 없습니다</option>
|
||||
</select>
|
||||
|
||||
<!-- 데이터가 있는 경우 원래 select 표시 -->
|
||||
<select v-else class="form-select" :id="name" v-model="selectData" :disabled="disabled" :style="isColor ? { color: selected } : {}" @blur="$emit('blur')">
|
||||
<option v-for="(item, i) in data" :key="i" :value="isCommon ? item.value : i" :style="isColor ? { color: item.label } : {}">
|
||||
{{ isCommon ? item.label : item }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<div v-if="isBtn" class="ms-2">
|
||||
<slot name="append"></slot>
|
||||
</div>
|
||||
|
||||
<div v-if="isColor && selected"
|
||||
class="w-px-40 h-px-30"
|
||||
:style="{backgroundColor: selected}">
|
||||
</div>
|
||||
|
||||
<img v-if="isMbti && selected"
|
||||
role="img"
|
||||
class="w-px-30 h-px-40"
|
||||
:src="`/img/mbti/${selected.toLowerCase()}.png`"
|
||||
alt="MBTI image"/>
|
||||
</div>
|
||||
|
||||
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }}를 확인해주세요.</div>
|
||||
<div class="col-md-10">
|
||||
<select class="form-select" id="input-ss" v-model="selectData">
|
||||
<option v-for="(item , i) in data" :key="item" :value="i" :selected="value == i">{{ item }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="isAlert" class="invalid-feedback">{{ title }}을 확인해주세요.</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ref, watch, watchEffect } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
@ -63,86 +38,21 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: [String, Number],
|
||||
type: String,
|
||||
default: '0',
|
||||
require: false,
|
||||
},
|
||||
isAlert: {
|
||||
isAlert : {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
isLabel: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
required: false,
|
||||
},
|
||||
isRow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
required: false,
|
||||
},
|
||||
isBtn: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
isCommon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
isColor: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
isMbti: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:data', 'blur']);
|
||||
const emit = defineEmits(['update:data']);
|
||||
const selectData = ref(props.value);
|
||||
|
||||
// props.value의 변경을 감지하는 watch 추가
|
||||
watch(() => props.value, (newValue) => {
|
||||
selectData.value = newValue;
|
||||
}, { immediate: true });
|
||||
|
||||
// data 변경 감지 수정
|
||||
watch(() => props.data, (newData) => {
|
||||
if (props.isCommon && newData.length > 0) {
|
||||
// value prop이 '0'(기본값)일 때만 첫번째 아이템 선택
|
||||
if (props.value === '0') {
|
||||
selectData.value = newData[0].value;
|
||||
emit('update:data', selectData.value);
|
||||
|
||||
if (props.isColor) {
|
||||
emit('blur');
|
||||
}
|
||||
}
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
// selectData 변경 감지
|
||||
watch(selectData, (newValue) => {
|
||||
emit('update:data', newValue);
|
||||
});
|
||||
|
||||
|
||||
const selected = computed(() => {
|
||||
// 현재 선택된 값에 해당하는 아이템 찾기
|
||||
const selectedItem = props.data.find(item =>
|
||||
props.isCommon ? item.value === selectData.value : props.data.indexOf(item) === selectData.value
|
||||
);
|
||||
|
||||
return selectedItem ? selectedItem.label : null;
|
||||
});
|
||||
watchEffect(() => {
|
||||
emit('update:data', selectData.value);
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -1,136 +0,0 @@
|
||||
<template>
|
||||
<div class="mb-2">
|
||||
<label :for="name" class="col-md-2 col-form-label">
|
||||
{{ title }}
|
||||
<span :class="isEssential ? 'link-danger' : 'd-none'">*</span>
|
||||
</label>
|
||||
<div class="col-md-12">
|
||||
<div v-if="useInputGroup" class="input-group mb-1">
|
||||
<input
|
||||
:id="name"
|
||||
class="form-control"
|
||||
:type="type"
|
||||
@input="updateInput"
|
||||
:value="computedValue"
|
||||
:disabled="disabled"
|
||||
:maxLength="maxlength"
|
||||
:placeholder="placeholder ? placeholder : title"
|
||||
@blur="$emit('blur')"
|
||||
/>
|
||||
<span class="input-group-text">@ localhost.co.kr</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<input
|
||||
:id="name"
|
||||
class="form-control"
|
||||
:type="type"
|
||||
:max="type === 'date' ? today : null"
|
||||
@input="updateInput"
|
||||
:value="computedValue"
|
||||
:disabled="disabled"
|
||||
:maxLength="maxlength"
|
||||
:placeholder="placeholder ? placeholder : title"
|
||||
@blur="$emit('blur')"
|
||||
@click="handleDateClick"
|
||||
ref="inputElement"
|
||||
/>
|
||||
</div>
|
||||
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }}를 확인해주세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { inject, computed, ref } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '라벨',
|
||||
required: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: 'nameplz',
|
||||
required: true,
|
||||
},
|
||||
isEssential: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
required: false,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
required: false,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
maxlength: {
|
||||
type: Number,
|
||||
default: 30,
|
||||
required: false,
|
||||
},
|
||||
isAlert: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
useInputGroup: {
|
||||
type: Boolean,
|
||||
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');
|
||||
|
||||
// 오늘 날짜를 YYYY-MM-DD 형식으로 변환
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
// date인 경우 기본값 -> 오늘 날짜
|
||||
const computedValue = computed(() => {
|
||||
return props.type === 'date' ? props.value || today : props.value;
|
||||
});
|
||||
|
||||
// 입력값 업데이트
|
||||
const updateInput = event => {
|
||||
const newValue = event.target.value.slice(0, props.maxlength);
|
||||
|
||||
// date인 경우 날짜 형식 유지
|
||||
if (props.type === 'date') {
|
||||
emits('update:data', newValue.replace(/[^0-9-]/g, ''));
|
||||
} else {
|
||||
emits('update:data', newValue);
|
||||
}
|
||||
|
||||
// 값이 입력될 때 isAlert를 false로 설정
|
||||
if (newValue.trim() !== '') {
|
||||
emits('update:alert', false);
|
||||
}
|
||||
};
|
||||
|
||||
// date 타입일 때 input 클릭 시 달력 열기
|
||||
const handleDateClick = (event) => {
|
||||
if (props.type === 'date' && inputElement.value) {
|
||||
// 프로그래매틱하게 달력 열기: 날짜 선택기 UI를 표시
|
||||
inputElement.value.showPicker();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
@ -1,46 +1,23 @@
|
||||
<template>
|
||||
<div class="card mb-3 shadow-sm">
|
||||
<div class="row g-0">
|
||||
<!-- 이미지 섹션 -->
|
||||
<div v-if="img" class="col-sm-2">
|
||||
<img
|
||||
:src="img"
|
||||
alt="이미지"
|
||||
class="img-fluid rounded-start"
|
||||
style="object-fit: cover; height: 100%; width: 100%;"
|
||||
/>
|
||||
</div>
|
||||
<!-- 게시물 내용 섹션 -->
|
||||
<div :class="contentColClass">
|
||||
<div class="card-body">
|
||||
<!-- 태그 -->
|
||||
<h6 class="badge rounded-pill bg-primary text-white mb-2">
|
||||
{{ category }}
|
||||
</h6>
|
||||
<!-- 제목 -->
|
||||
<h5 class="card-title">
|
||||
{{ title }}
|
||||
<span class="text-muted me-3" v-if="attachment">
|
||||
<i class="fa-solid fa-paperclip"></i>
|
||||
</span>
|
||||
</h5>
|
||||
<!-- 본문 -->
|
||||
<div class="card-text str_wrap my-5">{{ content }}</div>
|
||||
<!-- 날짜 -->
|
||||
<div class="d-flex flex-column flex-sm-row justify-content-between align-items-start">
|
||||
<small class="text-muted">{{ formattedDate }}</small>
|
||||
<!-- 조회수, 좋아요, 댓글 -->
|
||||
<div class="d-flex mt-2 mt-sm-0">
|
||||
<span class="text-muted me-3">
|
||||
<i class="fa-regular fa-eye"></i> {{ views || 0 }}
|
||||
</span>
|
||||
<span class="text-muted me-3" v-if="likes != null">
|
||||
<i class="bx bx-like"></i> {{ likes }}
|
||||
</span>
|
||||
<span class="text-muted" v-if="comments !== null">
|
||||
<i class="bx bx-comment"></i> {{ comments }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="" role="button">
|
||||
<div class="card">
|
||||
<div class="d-sm-flex">
|
||||
<div v-if="img">
|
||||
<img class="card-img card-img-left" :src="img" alt="" style="width: 200px; height: 200px; object-fit: cover" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title align-items-center">
|
||||
<slot name="badgeType"></slot>
|
||||
{{ title }}
|
||||
</h5>
|
||||
<p class="card-text str_wrap pt-5">
|
||||
{{ content }}
|
||||
</p>
|
||||
<p class="card-text">
|
||||
<small class="text-muted">{{ date }}</small>
|
||||
<slot name="optInfo"></slot>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -49,94 +26,40 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { defineProps } from 'vue';
|
||||
// const data = defineProps(['item']);
|
||||
const prop = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '제목',
|
||||
required: true,
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: '내용',
|
||||
required: true,
|
||||
},
|
||||
date: {
|
||||
type: String,
|
||||
default: 'date',
|
||||
required: true,
|
||||
},
|
||||
img: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Props 정의
|
||||
const props = defineProps({
|
||||
img: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
date: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
views: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
likes: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
comments: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
attachment: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
|
||||
// computed 속성
|
||||
const contentColClass = computed(() => {
|
||||
return props.img ? 'col-sm-10 col-12' : 'col-sm-12';
|
||||
});
|
||||
|
||||
// formattedDate을 computed로 정의
|
||||
const formattedDate = computed(() => {
|
||||
const date = new Date(props.date);
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
|
||||
date.getDate()
|
||||
).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(
|
||||
date.getMinutes()
|
||||
).padStart(2, "0")}`;
|
||||
});
|
||||
const colSetting = () => {
|
||||
img ? 'col-9' : '';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 카드 스타일 */
|
||||
.card {
|
||||
border: 1px solid #e6e6e6;
|
||||
border-radius: 8px;
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
/* 텍스트 줄임 표시 */
|
||||
.str_wrap {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
/* 이미지 스타일 */
|
||||
.img-fluid {
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
/* 태그 배지 스타일 */
|
||||
.badge {
|
||||
font-size: 0.8rem;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.str_wrap {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<div class="mt-4">
|
||||
<div v-if="posts.length === 0" class="text-center">
|
||||
<p class="text-muted mt-4">게시물이 없습니다.</p>
|
||||
</div>
|
||||
<div v-for="post in posts" :key="post.id" @click="handleClick(post.id)">
|
||||
<BoardCard
|
||||
:img="post.img || null"
|
||||
:category="post.category || ''"
|
||||
:title="post.title"
|
||||
:content="post.content"
|
||||
:date="post.date"
|
||||
:views="post.views || 0"
|
||||
v-bind="getBoardCardProps(post)"
|
||||
:attachment="post.attachment || false"
|
||||
@click="() => goDetail(post.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue';
|
||||
import BoardCard from './BoardCard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
posts: {
|
||||
type: Array,
|
||||
required: true,
|
||||
default: () => [],
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
// 상세 페이지 이동
|
||||
const goDetail = (id) => {
|
||||
emit('click', id);
|
||||
};
|
||||
|
||||
// 없는 데이터는 상위 페이지에서 삭제해도 되게끔 설정
|
||||
const getBoardCardProps = (post) => {
|
||||
const boardCardProps = {};
|
||||
if ('likes' in post) {
|
||||
boardCardProps.likes = post.likes;
|
||||
}
|
||||
if ('comments' in post) {
|
||||
boardCardProps.comments = post.comments;
|
||||
}
|
||||
return boardCardProps;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
@ -1,579 +0,0 @@
|
||||
<template>
|
||||
<div class="card mb-3 shadow-sm border" :class="isProjectExpired ? 'end-project' : ''">
|
||||
<div class="row g-0">
|
||||
<div class="card-body">
|
||||
<!-- 제목 -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<h5 class="card-title fw-bold">
|
||||
{{ title }}
|
||||
</h5>
|
||||
<div v-if="!isProjectExpired" class="d-flex gap-1">
|
||||
<EditBtn @click.stop="openEditModal" />
|
||||
<DeleteBtn v-if="isProjectCreator" @click.stop="handleDelete" class="ms-1"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 날짜 -->
|
||||
<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>
|
||||
|
||||
<!-- 참여자 -->
|
||||
<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>
|
||||
|
||||
<!-- 설명 -->
|
||||
<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>
|
||||
|
||||
<!-- 주소 -->
|
||||
<div class="row align-items-center pb-2">
|
||||
<div class="col-3 col-md-2 d-flex align-items-center">
|
||||
<MapPopover
|
||||
:address="address"
|
||||
:is-visible="isMapVisible"
|
||||
@update-visible="updatePopover"
|
||||
>
|
||||
<template #trigger>
|
||||
<div class="d-flex align-items-center cursor-pointer">
|
||||
<i class="bx bxs-map"></i>
|
||||
<div class="ms-2">주소</div>
|
||||
</div>
|
||||
</template>
|
||||
</MapPopover>
|
||||
</div>
|
||||
<div class="col-9 col-md-10 d-flex justify-content-between align-items-center">
|
||||
<div>{{ address }} {{ addressdtail }}</div>
|
||||
<button type="button" class="btn text-white" :style="`background-color: ${projctColor} !important;`" @click.stop="openModal">
|
||||
<i class='bx bx-child'></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그 모달 -->
|
||||
<CenterModal :display="isModalOpen" @close="closeModal">
|
||||
<template #title> 등록·수정자 </template>
|
||||
<template #body>
|
||||
<div v-if="logData.length > 0">
|
||||
<div
|
||||
v-for="(log, index) in logData"
|
||||
:key="index"
|
||||
class="ms-4 mt-2 border p-3"
|
||||
>
|
||||
<p class="mb-1">{{ log.logDate }}</p>
|
||||
<strong>{{ log.logMessage }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<BackBtn @click="closeModal" />
|
||||
</template>
|
||||
</CenterModal>
|
||||
|
||||
<!-- 수정 모달 -->
|
||||
<CenterModal :display="isEditModalOpen" @close="closeEditModal">
|
||||
<template #title> 프로젝트 수정 </template>
|
||||
<template #body>
|
||||
<FormInput
|
||||
title="프로젝트명"
|
||||
name="name"
|
||||
:is-essential="true"
|
||||
:is-alert="nameAlert"
|
||||
:modelValue="selectedProject.PROJCTNAM"
|
||||
@update:modelValue="selectedProject.PROJCTNAM = $event"
|
||||
@update:alert="nameAlert = $event"
|
||||
/>
|
||||
|
||||
<FormSelect
|
||||
title="컬러"
|
||||
name="color"
|
||||
:is-essential="true"
|
||||
:is-label="true"
|
||||
:is-common="true"
|
||||
:is-color="true"
|
||||
:data="allColors"
|
||||
:value="selectedProject.PROJCTCOL"
|
||||
@update:data="selectedProject.PROJCTCOL = $event"
|
||||
/>
|
||||
|
||||
<div class="mb-2 row">
|
||||
<label class="col-md-2 col-form-label">
|
||||
참여자
|
||||
</label>
|
||||
<div class="col-md-10">
|
||||
<UserList class="m-0"
|
||||
ref="editUserListRef"
|
||||
:projctSeq="projctSeq"
|
||||
:showOnlyActive="false"
|
||||
@user-list-update="handleEditUserListUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 시작일 -->
|
||||
<div @click="openStartDatePicker">
|
||||
<FormInput
|
||||
title="시작일"
|
||||
type="date"
|
||||
name="startDay"
|
||||
:is-essential="true"
|
||||
:is-alert="startDayAlert"
|
||||
:modelValue="selectedProject.PROJCTSTR"
|
||||
@update:modelValue="selectedProject.PROJCTSTR = $event"
|
||||
ref="startDateInput"
|
||||
/>
|
||||
</div>
|
||||
<!-- 종료일 -->
|
||||
<div @click="openEndDatePicker">
|
||||
<FormInput
|
||||
title="종료일"
|
||||
type="date"
|
||||
name="endDay"
|
||||
:min="selectedProject.PROJCTSTR"
|
||||
:modelValue="selectedProject.PROJCTEND"
|
||||
@update:modelValue="selectedProject.PROJCTEND = $event"
|
||||
ref="endDateInput"
|
||||
/>
|
||||
</div>
|
||||
<FormInput
|
||||
title="설명"
|
||||
name="description"
|
||||
:modelValue="selectedProject.PROJCTDES"
|
||||
@update:modelValue="selectedProject.PROJCTDES = $event"
|
||||
/>
|
||||
|
||||
<ArrInput
|
||||
title="주소"
|
||||
name="address"
|
||||
:is-essential="true"
|
||||
:is-row="true"
|
||||
:modelValue="{
|
||||
address: selectedProject.PROJCTARR,
|
||||
detailAddress: selectedProject.PROJCTDTL,
|
||||
postcode: selectedProject.PROJCTZIP
|
||||
}"
|
||||
@update:data="updateAddress"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<BackButton @click="closeEditModal" />
|
||||
<SaveButton @click="handleUpdate" />
|
||||
</template>
|
||||
</CenterModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, onMounted, ref, computed, watch } from 'vue';
|
||||
import UserList from '@c/user/UserList.vue';
|
||||
import CenterModal from '@c/modal/CenterModal.vue';
|
||||
import $api from '@api';
|
||||
import BackBtn from '@c/button/BackBtn.vue';
|
||||
import BackButton from '@c/button/BackBtn.vue';
|
||||
import SaveButton from '@c/button/SaveBtn.vue';
|
||||
import EditBtn from '../button/EditBtn.vue';
|
||||
import DeleteBtn from '../button/DeleteBtn.vue';
|
||||
import FormInput from '@c/input/FormInput.vue';
|
||||
import FormSelect from '@c/input/FormSelect.vue';
|
||||
import ArrInput from '@c/input/ArrInput.vue';
|
||||
import MapPopover from '@c/map/MapPopover.vue';
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
import { useUserInfoStore } from '@/stores/useUserInfoStore';
|
||||
import commonApi, { refreshColorList } from '@/common/commonApi';
|
||||
import { useProjectStore } from '@/stores/useProjectStore';
|
||||
|
||||
// 스토어
|
||||
const toastStore = useToastStore();
|
||||
const userStore = useUserInfoStore();
|
||||
const projectStore = useProjectStore();
|
||||
|
||||
// Props 정의
|
||||
const props = defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
strdate: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
enddate: {
|
||||
type: String,
|
||||
required: true,
|
||||
default: "",
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: "",
|
||||
},
|
||||
address: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
addressdtail: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
addressZip: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
projctSeq: {
|
||||
type: Number,
|
||||
required: false
|
||||
},
|
||||
projctCol: {
|
||||
type: Number,
|
||||
required: false
|
||||
},
|
||||
projctColor: {
|
||||
type: String,
|
||||
required: false
|
||||
},
|
||||
projctCreatorId: {
|
||||
type: Number,
|
||||
required: false
|
||||
},
|
||||
resetUserSelection: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
searchParams: {
|
||||
type: Object,
|
||||
default: () => ({ text: '', year: null })
|
||||
}
|
||||
});
|
||||
|
||||
// Emit 정의
|
||||
const emit = defineEmits(['update']);
|
||||
|
||||
// 로그 모달 상태
|
||||
const isModalOpen = ref(false);
|
||||
const logData = ref([]);
|
||||
|
||||
const isMapVisible = ref(null);
|
||||
|
||||
// 수정 모달 상태
|
||||
const isEditModalOpen = ref(false);
|
||||
const originalColor = ref('');
|
||||
const nameAlert = ref(false);
|
||||
const startDayAlert = ref(false);
|
||||
const user = ref(null);
|
||||
|
||||
const editUserListRef = ref(null);
|
||||
const userListRef = ref(null);
|
||||
|
||||
const selectedUsers = ref({
|
||||
activeUsers: [],
|
||||
disabledUsers: []
|
||||
});
|
||||
|
||||
|
||||
const startDateInput = ref(null);
|
||||
const endDateInput = ref(null);
|
||||
|
||||
// DOM 요소에 직접 접근하기 위한 변수들
|
||||
let startInputElement = null;
|
||||
let endInputElement = null;
|
||||
|
||||
|
||||
const openStartDatePicker = () => {
|
||||
if (startInputElement) {
|
||||
startInputElement.showPicker();
|
||||
}
|
||||
};
|
||||
|
||||
const openEndDatePicker = () => {
|
||||
if (endInputElement) {
|
||||
endInputElement.showPicker();
|
||||
}
|
||||
};
|
||||
|
||||
const updatePopover = (visible) => {
|
||||
isMapVisible.value = visible;
|
||||
};
|
||||
|
||||
// 사용자 목록 업데이트 핸들러
|
||||
const handleEditUserListUpdate = (userLists) => {
|
||||
selectedUsers.value = userLists;
|
||||
};
|
||||
|
||||
|
||||
const isProjectCreator = computed(() => {
|
||||
return user.value?.id === props.projctCreatorId;
|
||||
});
|
||||
|
||||
|
||||
// 프로젝트 만료 여부 체크 (종료일이 지났는지)
|
||||
const isProjectExpired = computed(() => {
|
||||
if (!props.enddate) return false;
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0); // 오늘 날짜의 시작 시간으로 설정
|
||||
|
||||
const endDate = new Date(props.enddate);
|
||||
endDate.setHours(0, 0, 0, 0); // 종료일의 시작 시간으로 설정
|
||||
|
||||
return endDate < today;
|
||||
});
|
||||
|
||||
// 수정할 프로젝트 데이터
|
||||
const selectedProject = ref({
|
||||
PROJCTSEQ: props.projctSeq,
|
||||
PROJCTNAM: props.title,
|
||||
PROJCTSTR: props.strdate,
|
||||
PROJCTEND: props.enddate,
|
||||
PROJCTZIP: props.addressZip,
|
||||
PROJCTARR: props.address,
|
||||
PROJCTDTL: props.addressdtail,
|
||||
PROJCTDES: props.description,
|
||||
PROJCTCOL: props.projctCol,
|
||||
projctcolor: props.projctColor,
|
||||
});
|
||||
|
||||
// 컬러 목록 가져오기
|
||||
const { colorList } = commonApi({
|
||||
loadColor: true,
|
||||
colorType: 'YNP',
|
||||
});
|
||||
|
||||
|
||||
// 기존 컬러 + 사용 가능 한 컬러
|
||||
const allColors = computed(() => {
|
||||
// 먼저 기존 컬러 객체를 생성 (이 컬러는 항상 목록에 포함되어야 함)
|
||||
const existingColor = {
|
||||
value: props.projctCol, // 원래 프로젝트의 컬러 값 사용
|
||||
label: props.projctColor // 원래 프로젝트의 컬러 레이블 사용
|
||||
};
|
||||
|
||||
// 중복 제거를 위해 기존 컬러 값과 다른 컬러만 필터링
|
||||
const otherColors = colorList.value.filter(color => color.value !== existingColor.value);
|
||||
|
||||
// 기존 컬러를 첫 번째로 놓고 나머지 컬러 추가
|
||||
return [existingColor, ...otherColors];
|
||||
});
|
||||
|
||||
|
||||
// 수정 :: 주소
|
||||
const updateAddress = addressData => {
|
||||
selectedProject.value = {
|
||||
...selectedProject.value,
|
||||
PROJCTZIP: addressData.postcode,
|
||||
PROJCTARR: addressData.address,
|
||||
PROJCTDTL: addressData.detailAddress,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// 로그 데이터 가져오기
|
||||
const getLogData = async () => {
|
||||
const res = await $api.get(`project/log/${props.projctSeq}`);
|
||||
|
||||
logData.value = res.data.data;
|
||||
};
|
||||
|
||||
// 로그 모달 열기
|
||||
const openModal = async () => {
|
||||
await getLogData();
|
||||
isModalOpen.value = true;
|
||||
};
|
||||
|
||||
// 로그 모달 닫기
|
||||
const closeModal = () => {
|
||||
isModalOpen.value = false;
|
||||
};
|
||||
|
||||
// 수정 모달 열기
|
||||
const openEditModal = async () => {
|
||||
selectedProject.value = {
|
||||
PROJCTSEQ: props.projctSeq,
|
||||
PROJCTNAM: props.title,
|
||||
PROJCTSTR: props.strdate,
|
||||
PROJCTEND: props.enddate,
|
||||
PROJCTZIP: props.addressZip,
|
||||
PROJCTARR: props.address,
|
||||
PROJCTDTL: props.addressdtail,
|
||||
PROJCTDES: props.description,
|
||||
PROJCTCOL: props.projctCol,
|
||||
projctcolor: props.projctColor,
|
||||
};
|
||||
|
||||
isEditModalOpen.value = true;
|
||||
originalColor.value = props.projctCol;
|
||||
|
||||
};
|
||||
|
||||
// 수정 모달 닫기
|
||||
const closeEditModal = () => {
|
||||
selectedProject.value = {
|
||||
PROJCTSEQ: props.projctSeq,
|
||||
PROJCTNAM: props.title,
|
||||
PROJCTSTR: props.strdate,
|
||||
PROJCTEND: props.enddate,
|
||||
PROJCTZIP: props.addressZip,
|
||||
PROJCTARR: props.address,
|
||||
PROJCTDTL: props.addressdtail,
|
||||
PROJCTDES: props.description,
|
||||
PROJCTCOL: props.projctCol,
|
||||
projctcolor: props.projctColor,
|
||||
};
|
||||
|
||||
isEditModalOpen.value = false;
|
||||
|
||||
// UserList의 resetSelection 메서드 호출
|
||||
if (editUserListRef.value) {
|
||||
editUserListRef.value.resetSelection();
|
||||
}
|
||||
};
|
||||
|
||||
// selectedUsers 값 변경 감지
|
||||
watch(() => selectedUsers.value.activeUsers, (newVal, oldVal) => {
|
||||
}, { deep: true });
|
||||
|
||||
watch(() => selectedUsers.value.disabledUsers, (newVal, oldVal) => {
|
||||
}, { deep: true });
|
||||
|
||||
// 변경된 내용 있는지 확인
|
||||
const hasChanges = computed(() => {
|
||||
// 기본 변경 확인 코드
|
||||
const basicChanges = selectedProject.value.PROJCTNAM !== props.title ||
|
||||
selectedProject.value.PROJCTSTR !== props.strdate ||
|
||||
selectedProject.value.PROJCTEND !== props.enddate ||
|
||||
selectedProject.value.PROJCTZIP !== props.addressZip ||
|
||||
selectedProject.value.PROJCTARR !== props.address ||
|
||||
selectedProject.value.PROJCTDTL !== props.addressdtail ||
|
||||
selectedProject.value.PROJCTDES !== props.description ||
|
||||
selectedProject.value.PROJCTCOL !== props.projctCol;
|
||||
|
||||
// 사용자 목록 변경 확인
|
||||
const userChanges = editUserListRef.value?.hasUserChanges() || false;
|
||||
|
||||
return basicChanges || userChanges;
|
||||
});
|
||||
|
||||
// 시작일 또는 종료일이 변경될 때 종료일의 최소값을 설정
|
||||
watch(
|
||||
() => selectedProject.value.PROJCTSTR, // 시작일 (strdate)
|
||||
(newStartDate) => {
|
||||
if (newStartDate && new Date(newStartDate) > new Date(selectedProject.value.PROJCTEND)) {
|
||||
// 시작일이 종료일보다 크면 종료일을 시작일로 설정
|
||||
selectedProject.value.PROJCTEND = newStartDate;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// resetUserSelection 변경 감지
|
||||
watch(() => props.resetUserSelection, () => {
|
||||
if (editUserListRef.value) {
|
||||
editUserListRef.value.resetSelection();
|
||||
}
|
||||
});
|
||||
|
||||
// 프로젝트 수정
|
||||
const handleUpdate = async () => {
|
||||
nameAlert.value = selectedProject.value.PROJCTNAM.trim() === '';
|
||||
startDayAlert.value = selectedProject.value.PROJCTSTR.trim() === '';
|
||||
|
||||
if (nameAlert.value || startDayAlert.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasChanges.value) {
|
||||
toastStore.onToast('변경된 내용이 없습니다.', 'e');
|
||||
return;
|
||||
}
|
||||
|
||||
const disabledMemberSeqs = selectedUsers.value.disabledUsers.map(user => user.MEMBERSEQ);
|
||||
|
||||
const res = await $api.patch('project/update', {
|
||||
projctSeq: selectedProject.value.PROJCTSEQ,
|
||||
projctNam: selectedProject.value.PROJCTNAM,
|
||||
projctCol: selectedProject.value.PROJCTCOL,
|
||||
projctArr: selectedProject.value.PROJCTARR,
|
||||
projctDtl: selectedProject.value.PROJCTDTL,
|
||||
projctZip: selectedProject.value.PROJCTZIP,
|
||||
projctStr: selectedProject.value.PROJCTSTR,
|
||||
projctEnd: selectedProject.value.PROJCTEND || null,
|
||||
projctDes: selectedProject.value.PROJCTDES || null,
|
||||
projctUmb: user.value?.id,
|
||||
originalColor: originalColor.value === selectedProject.value.PROJCTCOL ? null : originalColor.value,
|
||||
disabledMembers: disabledMemberSeqs
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
toastStore.onToast('수정이 완료 되었습니다.', 's');
|
||||
|
||||
// 프로젝트 목록 새로고침
|
||||
await projectStore.getProjectList(props.searchParams.text, props.searchParams.year, 'false');
|
||||
await projectStore.getMemberProjects();
|
||||
await refreshColorList('YNP');
|
||||
|
||||
await editUserListRef.value.fetchProjectParticipation();
|
||||
await userListRef.value.fetchProjectParticipation();
|
||||
|
||||
closeEditModal();
|
||||
emit('update', props.searchParams);
|
||||
}
|
||||
};
|
||||
|
||||
// 프로젝트 삭제
|
||||
const handleDelete = () => {
|
||||
if (confirm('프로젝트를 삭제하시겠습니까?')) {
|
||||
$api.patch('project/delete', {
|
||||
projctSeq: props.projctSeq,
|
||||
projctCol: props.projctCol,
|
||||
})
|
||||
.then(res => {
|
||||
if (res.status === 200) {
|
||||
toastStore.onToast('프로젝트가 삭제되었습니다.', 's');
|
||||
projectStore.getProjectList();
|
||||
projectStore.getMemberProjects();
|
||||
}
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
// 컴포넌트 마운트 시 실행
|
||||
onMounted(async () => {
|
||||
// 사용자 정보 가져오기
|
||||
await userStore.userInfo();
|
||||
user.value = userStore.user;
|
||||
|
||||
if (startDateInput.value) {
|
||||
// FormInput 내부 input 찾기
|
||||
startInputElement = startDateInput.value.$el.querySelector('input[type="date"]');
|
||||
}
|
||||
|
||||
if (endDateInput.value) {
|
||||
endInputElement = endDateInput.value.$el.querySelector('input[type="date"]');
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@ -1,233 +0,0 @@
|
||||
<template>
|
||||
<div class="col-md-6 col-lg-4 col-xl-4 order-0 mb-6">
|
||||
<div class="card text-center h-100">
|
||||
<!-- 더보기 버튼 -->
|
||||
<div class="d-flex">
|
||||
<router-link
|
||||
:to="{ name: 'BoardList', query: { type: selectedBoard } }"
|
||||
class="btn btn-primary mr-1 pe-1 ps-1 ms-auto my-auto h-50"
|
||||
>
|
||||
more
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 모달 본문 -->
|
||||
<div class="modal-body">
|
||||
<!-- 탭 버튼 영역 -->
|
||||
<div class="btn-group mb-5" role="group">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="selectedBoard === 'notices' ? 'btn-primary' : 'btn-outline-primary'"
|
||||
@click="changeBoard('notices')"
|
||||
>
|
||||
공지
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="selectedBoard === 'general' ? 'btn-primary' : 'btn-outline-primary'"
|
||||
@click="changeBoard('general')"
|
||||
>
|
||||
자유
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
:class="selectedBoard === 'anonymous' ? 'btn-primary' : 'btn-outline-primary'"
|
||||
@click="changeBoard('anonymous')"
|
||||
>
|
||||
익명
|
||||
</button>
|
||||
</div>
|
||||
<!-- 게시글 미리보기 테이블 -->
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<!-- 익명게시판은 '닉네임', 나머지는 '작성자' -->
|
||||
<th class="text-start">
|
||||
<div class="ms-4">
|
||||
{{ selectedBoard === 'anonymous' ? '닉네임' : '작성자' }}
|
||||
</div>
|
||||
</th>
|
||||
<th class="text-start" style="width: 65%;">
|
||||
<div class="ms-4">
|
||||
제목
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="post in currentList"
|
||||
:key="post.id"
|
||||
style="cursor: pointer;"
|
||||
@click="goDetail(post.id, selectedBoard)"
|
||||
>
|
||||
<td class="text-start nickname-ellipsis small">
|
||||
<div class="ms-4">
|
||||
{{ selectedBoard === 'anonymous' ? post.nickname : post.author }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-start fs-6">
|
||||
<div class="ms-4">
|
||||
{{ truncateTitle(post.title) }}
|
||||
<span v-if="post.commentCount" class="text-danger ml-1 small">
|
||||
[{{ post.commentCount }}]
|
||||
</span>
|
||||
<i v-if="post.img" class="bi bi-image mx-1 small"></i>
|
||||
<i
|
||||
v-if="post.hasAttachment.length > 0"
|
||||
class="bi bi-paperclip ml-1 small"
|
||||
></i>
|
||||
|
||||
<div class="text-muted small small">
|
||||
{{ post.date }}
|
||||
<span class="ms-1">
|
||||
<i class="fa-regular fa-eye small me-1"></i>{{post.views}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="currentList.length === 0">
|
||||
<td colspan="3" class="text-center text-muted">게시물이 없습니다.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import axios from '@api';
|
||||
import dayjs from 'dayjs';
|
||||
import isToday from 'dayjs/plugin/isToday';
|
||||
import isYesterday from 'dayjs/plugin/isYesterday';
|
||||
import 'bootstrap-icons/font/bootstrap-icons.css';
|
||||
|
||||
dayjs.extend(isToday);
|
||||
dayjs.extend(isYesterday);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// 모달이 항상 보이도록 초기값 true
|
||||
const isModalOpen = ref(true);
|
||||
|
||||
// 현재 선택된 게시판 타입: 'notices', 'general', 'anonymous'
|
||||
const selectedBoard = ref('notices');
|
||||
|
||||
// 각 게시판 미리보기 데이터 배열
|
||||
const noticeList = ref([]);
|
||||
const freeList = ref([]);
|
||||
const anonymousList = ref([]);
|
||||
|
||||
// 선택된 게시판에 따른 미리보기 목록 computed
|
||||
const currentList = computed(() => {
|
||||
if (selectedBoard.value === 'notices') return noticeList.value;
|
||||
if (selectedBoard.value === 'general') return freeList.value;
|
||||
if (selectedBoard.value === 'anonymous') return anonymousList.value;
|
||||
return [];
|
||||
});
|
||||
|
||||
// 날짜 포맷 함수: 오늘이면 HH:mm, 아니면 YYYY-MM-DD
|
||||
const formatDate = dateString => {
|
||||
const date = dayjs(dateString);
|
||||
return date.isToday() ? date.format('HH:mm') : date.format('YYYY-MM-DD');
|
||||
};
|
||||
|
||||
// 제목이 14글자 넘어가면 ... 처리하는 함수
|
||||
const truncateTitle = title => {
|
||||
return title.length > 7 ? title.slice(0, 7) + '...' : title;
|
||||
};
|
||||
|
||||
// 공지사항 데이터 로드 (최대 5개)
|
||||
const fetchNoticePosts = async () => {
|
||||
try {
|
||||
const { data } = await axios.get('board/notices', { params: { size: 8 } });
|
||||
if (data?.data) {
|
||||
noticeList.value = data.data.map(post => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
date: formatDate(post.date),
|
||||
rawDate: post.date,
|
||||
views: post.cnt || 0,
|
||||
commentCount: post.commentCount,
|
||||
img: post.firstImageUrl,
|
||||
author: post.author || '관리자',
|
||||
nickname: post.nickname || '관리자',
|
||||
hasAttachment: post.hasAttachment, // 첨부파일 유무
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
};
|
||||
|
||||
// board/general 게시글 로드 후 자유게시판과 익명게시판으로 분리 (최대 10개 조회 → 각각 최대 5개)
|
||||
const fetchGeneralPosts = async () => {
|
||||
try {
|
||||
const { data } = await axios.get('board/general', { params: { size: 16 } });
|
||||
if (data?.data && data.data.list) {
|
||||
const freePosts = [];
|
||||
const anonymousPosts = [];
|
||||
data.data.list.forEach(post => {
|
||||
if (post.nickname) {
|
||||
// 닉네임이 있으면 익명게시판 데이터
|
||||
anonymousPosts.push({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
date: formatDate(post.date),
|
||||
img: post.firstImageUrl,
|
||||
rawDate: post.date,
|
||||
views: post.cnt || 0,
|
||||
commentCount: post.commentCount,
|
||||
nickname: post.nickname,
|
||||
hasAttachment: post.hasAttachment, // 첨부파일 유무
|
||||
});
|
||||
} else {
|
||||
// 닉네임이 없으면 자유게시판 데이터
|
||||
freePosts.push({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
date: formatDate(post.date),
|
||||
rawDate: post.date,
|
||||
views: post.cnt || 0,
|
||||
img: post.firstImageUrl,
|
||||
commentCount: post.commentCount,
|
||||
author: post.author || '익명',
|
||||
hasAttachment: post.hasAttachment, // 첨부파일 유무
|
||||
});
|
||||
}
|
||||
});
|
||||
freeList.value = freePosts.slice(0, 8);
|
||||
anonymousList.value = anonymousPosts.slice(0, 8);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
// 탭 변경 함수
|
||||
const changeBoard = type => {
|
||||
selectedBoard.value = type;
|
||||
};
|
||||
|
||||
// 상세 페이지 이동 (게시판 타입 전달)
|
||||
const goDetail = (id, boardType) => {
|
||||
router.push({ name: 'BoardDetail', params: { id }, query: { type: boardType } });
|
||||
};
|
||||
|
||||
// 모달이 열릴 때 데이터 로드
|
||||
fetchNoticePosts();
|
||||
fetchGeneralPosts();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.table > :not(caption) > * > * {
|
||||
padding: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@ -1,260 +0,0 @@
|
||||
<template>
|
||||
<div class="event-modal position-fixed bg-white shadow rounded" :style="modalStyle">
|
||||
<!-- 이벤트 선택 화면 -->
|
||||
<div v-if="!selectedEventType" class="d-flex flex-wrap gap-2 p-2">
|
||||
<div v-for="event in eventTypes" :key="event.code" class="event-icon-wrapper position-relative">
|
||||
<img
|
||||
:src="`${baseUrl}img/main-category-img/main-${event.code}.png`"
|
||||
class="event-icon-select"
|
||||
style="width: 25px; height: 25px; cursor: pointer"
|
||||
@click="handleEventClick(event)"
|
||||
/>
|
||||
<!-- X 표시 수정 -->
|
||||
<span v-if="isEventExists(event.type)" class="cancel-mark"> × </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 입력 폼 화면 -->
|
||||
<div v-else class="p-2" style="min-width: 200px">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<small>{{ getEventTitle(selectedEventType) }}</small>
|
||||
<button class="btn-close btn-close-sm" style="font-size: 8px" @click="resetForm"></button>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control form-control-sm py-1"
|
||||
style="height: 25px; font-size: 12px"
|
||||
placeholder="장소"
|
||||
v-model="eventPlace"
|
||||
maxlength="20"
|
||||
@input="handleChangeInput"
|
||||
/>
|
||||
<span v-if="noInputAlert" class="invalid-feedback d-block" style="padding-left: 5px">{{ noInputAlert }}</span>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<div @click="focusPicker">
|
||||
<input
|
||||
type="time"
|
||||
class="form-control form-control-sm py-1"
|
||||
style="height: 0%; font-size: 12px"
|
||||
v-model="eventTime"
|
||||
@input="handleChangeInput2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="timeInput"
|
||||
type="time"
|
||||
class="hidden-time-input"
|
||||
style="height: 0%; font-size: 12px"
|
||||
v-model="eventTime"
|
||||
@input="handleChangeInput2"
|
||||
/>
|
||||
<span v-if="noInputAlert2" class="invalid-feedback d-block" style="padding-left: 5px">{{ noInputAlert2 }}</span>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<button class="btn btn-primary btn-sm py-1" style="font-size: 12px; height: 25px; line-height: 1" @click="handleSubmit">
|
||||
등록
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
|
||||
const props = defineProps({
|
||||
position: {
|
||||
type: Object,
|
||||
required: true,
|
||||
default: () => ({ x: 0, y: 0 }),
|
||||
},
|
||||
selectedDate: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
baseUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
dateEvents: {
|
||||
type: Array,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['select', 'delete', 'insert']);
|
||||
|
||||
// 폼 관련 상태
|
||||
const selectedEventType = ref(null);
|
||||
const eventPlace = ref('');
|
||||
const eventTime = ref('');
|
||||
const noInputAlert = ref(null);
|
||||
const noInputAlert2 = ref(null);
|
||||
const timeInput = ref(null);
|
||||
|
||||
const focusPicker = () => {
|
||||
if (timeInput.value) {
|
||||
timeInput.value.showPicker(); // 달력 자동 열기 (일부 브라우저에서 지원)
|
||||
timeInput.value.focus(); // 포커스 이동
|
||||
}
|
||||
};
|
||||
|
||||
const eventTypes = [
|
||||
{ type: 'birthdayParty', code: '300203', title: '생일파티' },
|
||||
{ type: 'dinner', code: '300204', title: '회식' },
|
||||
{ type: 'teaTime', code: '300205', title: '티타임' },
|
||||
{ type: 'workshop', code: '300206', title: '워크샵' },
|
||||
];
|
||||
|
||||
const getEventTitle = type => {
|
||||
return eventTypes.find(event => event.code === type)?.title || '';
|
||||
};
|
||||
|
||||
const isEventExists = type => {
|
||||
return props.dateEvents?.some(event => event.type === type);
|
||||
};
|
||||
|
||||
const handleEventClick = event => {
|
||||
if (isEventExists(event.type)) {
|
||||
if (confirm('이벤트를 취소하시겠습니까?')) {
|
||||
emit('delete', {
|
||||
date: props.selectedDate,
|
||||
code: event.code,
|
||||
title: event.title,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
selectedEventType.value = event.code;
|
||||
noInputAlert.value = '';
|
||||
noInputAlert2.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (isValid()) {
|
||||
emit('insert', {
|
||||
date: props.selectedDate,
|
||||
code: selectedEventType.value,
|
||||
title: getEventTitle(selectedEventType.value),
|
||||
place: eventPlace.value.trim(),
|
||||
time: eventTime.value,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 유효성 검사
|
||||
const isValid = () => {
|
||||
let isValid = true;
|
||||
|
||||
if (!eventPlace.value.trim()) {
|
||||
noInputAlert.value = '장소를 입력해 주세요';
|
||||
isValid = false;
|
||||
} else {
|
||||
noInputAlert.value = '';
|
||||
}
|
||||
|
||||
if (!eventTime.value) {
|
||||
noInputAlert2.value = '시간을 입력해 주세요';
|
||||
isValid = false;
|
||||
} else {
|
||||
noInputAlert2.value = '';
|
||||
}
|
||||
|
||||
return isValid;
|
||||
};
|
||||
|
||||
const handleChangeInput = () => {
|
||||
noInputAlert.value = null;
|
||||
};
|
||||
|
||||
const handleChangeInput2 = () => {
|
||||
noInputAlert2.value = null;
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
selectedEventType.value = null;
|
||||
eventPlace.value = '';
|
||||
eventTime.value = '';
|
||||
};
|
||||
|
||||
// 모달 스타일 계산을 computed로 변경
|
||||
const modalStyle = computed(() => {
|
||||
const modalWidth = 200; // 모달의 예상 너비
|
||||
const modalHeight = 150; // 모달의 예상 높이
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
let x = props.position?.x || 0;
|
||||
let y = props.position?.y || 0;
|
||||
|
||||
// 모달이 뷰포트를 벗어나지 않도록 조정
|
||||
if (x + modalWidth > viewportWidth) {
|
||||
x = viewportWidth - modalWidth - 10;
|
||||
}
|
||||
if (x < 0) {
|
||||
x = 10;
|
||||
}
|
||||
if (y + modalHeight > viewportHeight) {
|
||||
y = viewportHeight - modalHeight - 10;
|
||||
}
|
||||
if (y < 0) {
|
||||
y = 10;
|
||||
}
|
||||
|
||||
return {
|
||||
left: `${x}px`,
|
||||
top: `${y}px`,
|
||||
zIndex: 1050,
|
||||
maxWidth: '90vw', // 뷰포트 너비의 90%를 넘지 않도록
|
||||
maxHeight: '90vh', // 뷰포트 높이의 90%를 넘지 않도록
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event-icon-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.event-icon-select {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.event-icon-select:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.cancel-mark {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.event-modal {
|
||||
min-width: 120px;
|
||||
max-width: 300px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* 작은 화면에서의 스타일 */
|
||||
@media (max-width: 576px) {
|
||||
.event-modal {
|
||||
min-width: 100px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,30 +0,0 @@
|
||||
<template>
|
||||
<div class="ps-2" style="font-size: 13px">
|
||||
<span class="d-flex align-items-center g-2 font_767"><i class="bx bxs-map pe-1"></i>{{ place }}</span>
|
||||
<span class="d-flex align-items-center g-2 font_767"
|
||||
><i class="bx bxs-time-five pe-1"></i>{{ $common.dateFormatter(time, 'T') }}</span
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
place: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
time: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style>
|
||||
@media (max-width: 767px) {
|
||||
.font_767 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,724 +0,0 @@
|
||||
<template>
|
||||
<div class="card app-calendar-wrapper">
|
||||
<div class="row g-0">
|
||||
<div class="col-3 border-end text-center" id="app-calendar-sidebar">
|
||||
<div class="card-body">
|
||||
<img
|
||||
v-if="user"
|
||||
:src="`${profileImgUrl}profile/${user.profile}`"
|
||||
alt="Profile Image"
|
||||
class="w-px-50 h-px-50 rounded-circle profile-img"
|
||||
@error="$event.target.src = '/img/icons/icon.png'"
|
||||
/>
|
||||
<p class="mt-2 fw-bold">
|
||||
{{ user.name }}
|
||||
</p>
|
||||
|
||||
<CommuterBtn
|
||||
ref="workTimeComponentRef"
|
||||
:userId="user.id"
|
||||
:checkedInProject="checkedInProject || {}"
|
||||
:pendingProjectChange="pendingProjectChange"
|
||||
@update:pendingProjectChange="pendingProjectChange = $event"
|
||||
@leaveTimeUpdated="handleLeaveTimeUpdate"
|
||||
/>
|
||||
|
||||
<MainEventList
|
||||
:categoryList="categoryList"
|
||||
:baseUrl="baseUrl"
|
||||
:birthdayList="birthdayList"
|
||||
:vacationList="vacationList"
|
||||
:birthdayPartyList="birthdayPartyList"
|
||||
:dinnerList="dinnerList"
|
||||
:teaTimeList="teaTimeList"
|
||||
:workShopList="workShopList"
|
||||
@handle-click-vacation="handleClickVacation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col app-calendar-content">
|
||||
<div class="card shadow-none border-0">
|
||||
<div class="card-body">
|
||||
<full-calendar
|
||||
ref="fullCalendarRef"
|
||||
:events="calendarEvents"
|
||||
:options="calendarOptions"
|
||||
defaultView="dayGridMonth"
|
||||
class="flatpickr-calendar-only"
|
||||
>
|
||||
</full-calendar>
|
||||
<input ref="calendarDatepicker" type="text" class="d-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EventModal
|
||||
v-if="showModal"
|
||||
:position="modalPosition"
|
||||
:selected-date="selectedDate"
|
||||
:base-url="baseUrl"
|
||||
:date-events="currentDateEvents"
|
||||
@select="handleEventSelect"
|
||||
@delete="handleEventDelete"
|
||||
@insert="handleEventInsert"
|
||||
@close="handleCloseModal"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { inject, onMounted, reactive, ref, watch, nextTick } from 'vue';
|
||||
import { fetchHolidays } from '@c/calendar/holiday';
|
||||
import { useUserInfoStore } from '@/stores/useUserInfoStore';
|
||||
import { useProjectStore } from '@/stores/useProjectStore';
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
import { useWeatherStore } from '@/stores/useWeatherStore';
|
||||
import { useDatePicker } from '@/stores/useDatePicker';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import router from '@/router';
|
||||
import FullCalendar from '@fullcalendar/vue3';
|
||||
import dayGridPlugin from '@fullcalendar/daygrid';
|
||||
import interactionPlugin from '@fullcalendar/interaction';
|
||||
import CommuterBtn from '@c/commuters/CommuterBtn.vue';
|
||||
import MainEventList from '@c/main/MainEventList.vue';
|
||||
import EventModal from '@c/main/EventModal.vue';
|
||||
import $api from '@api';
|
||||
import 'flatpickr/dist/flatpickr.min.css';
|
||||
import '@/assets/css/app-calendar.css';
|
||||
|
||||
const baseUrl = import.meta.env.VITE_DOMAIN;
|
||||
const profileImgUrl = import.meta.env.VITE_SERVER_IMG_URL;
|
||||
const user = ref({});
|
||||
const userStore = useUserInfoStore();
|
||||
const projectStore = useProjectStore();
|
||||
const weatherStore = useWeatherStore();
|
||||
const datePickerStore = useDatePicker();
|
||||
const { dailyWeatherList } = storeToRefs(weatherStore);
|
||||
|
||||
const dayjs = inject('dayjs');
|
||||
const fullCalendarRef = ref(null);
|
||||
const workTimeComponentRef = ref(null);
|
||||
const calendarEvents = ref([]);
|
||||
const calendarDatepicker = ref(null);
|
||||
//const dailyWeatherList = ref([]);
|
||||
|
||||
const selectedProject = ref(null);
|
||||
const checkedInProject = ref(null);
|
||||
const pendingProjectChange = ref(null);
|
||||
|
||||
// 이벤트 모달 관련
|
||||
const showModal = ref(false);
|
||||
const modalPosition = ref({ x: 0, y: 0 });
|
||||
const selectedDate = ref('');
|
||||
|
||||
// 공통 함수
|
||||
const $common = inject('common');
|
||||
const toastStore = useToastStore();
|
||||
|
||||
// 롱프레스 관련 변수 추가
|
||||
const pressTimer = ref(null);
|
||||
const longPressDelay = 500; // 0.5초
|
||||
|
||||
/************* category ***************/
|
||||
|
||||
// 이벤트 카테고리 데이터 로딩
|
||||
const categoryList = ref([]);
|
||||
const fetchCategoryList = async () => {
|
||||
const { data } = await $api.get('main/category');
|
||||
if (data) categoryList.value = [...data.data.filter(categoryInfo => categoryInfo.CMNCODODR != 0)];
|
||||
};
|
||||
|
||||
/************* init ***************/
|
||||
const monthBirthdayList = ref([]);
|
||||
const monthVacationList = ref([]);
|
||||
const monthBirthdayPartyList = ref([]);
|
||||
const monthDinnerList = ref([]);
|
||||
const monthTeaTimeList = ref([]);
|
||||
const monthWorkShopList = ref([]);
|
||||
|
||||
const birthdayList = ref([]);
|
||||
const vacationList = ref([]);
|
||||
const birthdayPartyList = ref([]);
|
||||
const dinnerList = ref([]);
|
||||
const teaTimeList = ref([]);
|
||||
const workShopList = ref([]);
|
||||
|
||||
const currentDateEvents = ref([]);
|
||||
|
||||
// 생일자, 휴가자, 이벤트 일정 조회
|
||||
const fetchEventList = async param => {
|
||||
const { data } = await $api.get(`main/eventList?${param}`);
|
||||
const res = data?.data;
|
||||
|
||||
// 기존의 공휴일 이벤트는 유지
|
||||
const holidayEvents = calendarEvents.value.filter(event => event.classNames?.includes('holiday-event'));
|
||||
calendarEvents.value = [...holidayEvents, ...dailyWeatherList.value];
|
||||
|
||||
// 생일자
|
||||
if (res?.memberBirthdayList?.length) {
|
||||
monthBirthdayList.value = [...res.memberBirthdayList];
|
||||
res.memberBirthdayList.forEach(member => {
|
||||
addEvent($common.dateFormatter(member.MEMBERBTH, 'YMD'), 'birthday', `${member.MEMBERNAM}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 휴가자
|
||||
if (res?.memberVacationList?.length) {
|
||||
monthVacationList.value = [...res.memberVacationList];
|
||||
res.memberVacationList.forEach(member => {
|
||||
addEvent($common.dateFormatter(member.LOCVACUDT, 'YMD'), 'vacation', `${member.MEMBERNAM}`);
|
||||
});
|
||||
}
|
||||
|
||||
// 초기화
|
||||
monthBirthdayPartyList.value = [];
|
||||
monthDinnerList.value = [];
|
||||
monthTeaTimeList.value = [];
|
||||
monthWorkShopList.value = [];
|
||||
|
||||
if (res?.eventList?.length) {
|
||||
res.eventList.forEach(item => {
|
||||
switch (item.CMNCODVAL) {
|
||||
case 300203:
|
||||
monthBirthdayPartyList.value = [...monthBirthdayPartyList.value, item];
|
||||
addEvent($common.dateFormatter(item.LOCEVTTME, 'YMD'), 'birthdayParty', '생일파티');
|
||||
break;
|
||||
|
||||
case 300204:
|
||||
monthDinnerList.value = [...monthDinnerList.value, item];
|
||||
addEvent($common.dateFormatter(item.LOCEVTTME, 'YMD'), 'dinner', '회식');
|
||||
break;
|
||||
|
||||
case 300205:
|
||||
monthTeaTimeList.value = [...monthTeaTimeList.value, item];
|
||||
addEvent($common.dateFormatter(item.LOCEVTTME, 'YMD'), 'teaTime', '티타임');
|
||||
break;
|
||||
|
||||
case 300206:
|
||||
monthWorkShopList.value = [...monthWorkShopList.value, item];
|
||||
addEvent($common.dateFormatter(item.LOCEVTTME, 'YMD'), 'workshop', '워크샵');
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 달력에 이벤트 데이터 추가
|
||||
const addEvent = (date, type, title) => {
|
||||
// 생일의 경우 달력의 현재 년도로 변경하여 처리
|
||||
if (type === 'birthday') {
|
||||
const calendarApi = fullCalendarRef.value?.getApi();
|
||||
|
||||
if (calendarApi) {
|
||||
const calendarDate = calendarApi.currentData.currentDate;
|
||||
const { year } = $common.formatDateTime(new Date(calendarDate));
|
||||
const birthDate = $common.dateFormatter(date, 'MD');
|
||||
date = `${year}-${birthDate}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 같은 날짜와 타입의 이벤트가 이미 있는지 확인
|
||||
const existingEvent = calendarEvents.value.find(
|
||||
event => $common.dateFormatter(event.start, 'MD') === $common.dateFormatter(date, 'MD') && event.type == type,
|
||||
);
|
||||
|
||||
// 없는 경우에만 추가
|
||||
if (!existingEvent) {
|
||||
calendarEvents.value.push({
|
||||
start: date,
|
||||
type: type,
|
||||
title: title,
|
||||
classNames: [`${type}-event`],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 해당일 기준 이벤트 리스트 필터링
|
||||
const useFilterEventList = (month, day) => {
|
||||
// 생일자
|
||||
if (monthBirthdayList.value) {
|
||||
birthdayList.value = $common.filterTargetByDate(monthBirthdayList.value, 'MEMBERBTH', month, day);
|
||||
}
|
||||
|
||||
// 휴가자
|
||||
if (monthVacationList.value) {
|
||||
vacationList.value = $common.filterTargetByDate(monthVacationList.value, 'LOCVACUDT', month, day);
|
||||
}
|
||||
|
||||
// 생일파티
|
||||
if (monthBirthdayPartyList.value) {
|
||||
birthdayPartyList.value = $common.filterTargetByDate(monthBirthdayPartyList.value, 'LOCEVTTME', month, day);
|
||||
}
|
||||
|
||||
// 회식
|
||||
if (monthDinnerList.value) {
|
||||
dinnerList.value = $common.filterTargetByDate(monthDinnerList.value, 'LOCEVTTME', month, day);
|
||||
}
|
||||
|
||||
// 티타임
|
||||
if (monthTeaTimeList.value) {
|
||||
teaTimeList.value = $common.filterTargetByDate(monthTeaTimeList.value, 'LOCEVTTME', month, day);
|
||||
}
|
||||
|
||||
// 워크샵
|
||||
if (monthWorkShopList.value) {
|
||||
workShopList.value = $common.filterTargetByDate(monthWorkShopList.value, 'LOCEVTTME', month, day);
|
||||
}
|
||||
};
|
||||
|
||||
// 캘린더 데이터 가져오기
|
||||
const fetchData = async () => {
|
||||
// FullCalendar API 인스턴스 가져오기
|
||||
const calendarApi = fullCalendarRef.value?.getApi();
|
||||
if (!calendarApi) return;
|
||||
|
||||
const date = calendarApi.currentData.currentDate;
|
||||
const { year, month } = $common.formatDateTime(new Date(date));
|
||||
|
||||
try {
|
||||
// 현재 표시 중인 월의 공휴일 정보 가져오기
|
||||
const holidayEvents = await fetchHolidays(year, month);
|
||||
calendarEvents.value = [...holidayEvents]; // 공휴일 정보로 초기화
|
||||
|
||||
// 이벤트 데이터 가져오기
|
||||
const param = new URLSearchParams();
|
||||
param.append('year', year);
|
||||
param.append('month', month);
|
||||
param.append('day', '1'); // 해당 월의 첫날
|
||||
|
||||
await fetchEventList(param);
|
||||
} catch (error) {
|
||||
console.error('공휴일 정보 로딩 실패:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 캘린더 이동 함수 (이전, 다음, 오늘)
|
||||
const moveCalendar = async (value = 0) => {
|
||||
const calendarApi = fullCalendarRef.value?.getApi();
|
||||
|
||||
if (value === 1) {
|
||||
calendarApi.prev(); // 이전 달로 이동
|
||||
} else if (value === 2) {
|
||||
calendarApi.next(); // 다음 달로 이동
|
||||
} else if (value === 3) {
|
||||
calendarApi.today(); // 오늘 날짜로 이동
|
||||
}
|
||||
|
||||
await fetchData();
|
||||
};
|
||||
|
||||
// 날짜 선택 가능 여부를 확인하는 공통 함수
|
||||
const isSelectableDate = date => {
|
||||
const checkDate = dayjs(date);
|
||||
const isWeekend = checkDate.day() === 0 || checkDate.day() === 6; // 주말체크
|
||||
// 공휴일 체크
|
||||
const isHoliday = calendarEvents.value.some(
|
||||
event =>
|
||||
event.classNames?.includes('holiday-event') && dayjs(event.start).format('YYYY-MM-DD') === checkDate.format('YYYY-MM-DD'),
|
||||
);
|
||||
|
||||
return !isWeekend && !isHoliday;
|
||||
};
|
||||
|
||||
// 날짜 셀 클래스 추가 함수
|
||||
const getCellClassNames = arg => {
|
||||
const cellDate = dayjs(arg.date);
|
||||
const classes = [];
|
||||
|
||||
// 선택 불가능한 날짜(과거, 주말, 공휴일)에 동일한 클래스 추가
|
||||
if (!isSelectableDate(cellDate)) {
|
||||
classes.push('fc-day-sat-sun');
|
||||
} else {
|
||||
// 선택 가능한 날짜 포인터 클래스 추가
|
||||
classes.push('clickable');
|
||||
}
|
||||
|
||||
return classes;
|
||||
};
|
||||
|
||||
// 날짜 클릭 이벤트 핸들러
|
||||
let todayEL = null;
|
||||
const handleDateClick = info => {
|
||||
if (isSelectableDate(info.date)) {
|
||||
if ($common.isToday(info.date)) {
|
||||
// 오늘 날짜 클릭 시 클래스 제거하고 요소 저장
|
||||
todayEL = info.dayEl;
|
||||
todayEL.classList.remove('fc-day-today');
|
||||
} else if (todayEL) {
|
||||
// 다른 날짜 클릭 시 저장된 오늘 요소에 클래스 다시 추가
|
||||
todayEL.classList.add('fc-day-today');
|
||||
todayEL = null;
|
||||
}
|
||||
}
|
||||
const { month, day } = $common.formatDateTime(new Date(info.dateStr));
|
||||
useFilterEventList(month, day);
|
||||
};
|
||||
|
||||
// 오늘 날짜 노란색 배경 복구
|
||||
const colorToday = e => {
|
||||
if (todayEL != null && !todayEL.classList.contains('fc-day-today')) todayEL.classList.add('fc-day-today');
|
||||
};
|
||||
|
||||
// 이벤트 모달 핸들러
|
||||
const handleMouseDown = (date, jsEvent) => {
|
||||
if (showModal.value) showModal.value = false;
|
||||
|
||||
// 해당 날짜의 이벤트 필터링
|
||||
const dateEvents = calendarEvents.value.filter(
|
||||
event => $common.dateFormatter(event.start, 'YMD') === $common.dateFormatter(date, 'YMD'),
|
||||
);
|
||||
|
||||
pressTimer.value = setTimeout(() => {
|
||||
modalPosition.value = {
|
||||
x: jsEvent.clientX,
|
||||
y: jsEvent.clientY,
|
||||
};
|
||||
|
||||
selectedDate.value = date;
|
||||
currentDateEvents.value = dateEvents;
|
||||
showModal.value = true;
|
||||
pressTimer.value = null;
|
||||
}, longPressDelay);
|
||||
};
|
||||
|
||||
// 이벤트 모달 외부 클릭 시 닫힘
|
||||
const handleMouseUp = () => {
|
||||
if (pressTimer.value) {
|
||||
clearTimeout(pressTimer.value);
|
||||
pressTimer.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 이벤트 삭제 api
|
||||
const toggleEvent = async (date, code, title) => {
|
||||
const { data } = await $api.post('main/toggleEvent', {
|
||||
date: date,
|
||||
code: code,
|
||||
title: title,
|
||||
});
|
||||
|
||||
if (data?.code === 200) toastStore.onToast(data.message);
|
||||
|
||||
const { year, month, day } = $common.formatDateTime(new Date(date));
|
||||
const param = new URLSearchParams();
|
||||
param.append('year', year);
|
||||
param.append('month', month);
|
||||
param.append('day', day);
|
||||
|
||||
await fetchEventList(param);
|
||||
useFilterEventList(month, day);
|
||||
};
|
||||
|
||||
// 이벤트 추가 api
|
||||
const insertEvent = async (date, code, title, place, time) => {
|
||||
const dateTime = $common.dateFormatter(`${date} ${time}`);
|
||||
const { data } = await $api.post('main/inserEvent', {
|
||||
date: dateTime,
|
||||
code: code,
|
||||
title: title,
|
||||
place: place,
|
||||
});
|
||||
|
||||
if (data?.code === 200) toastStore.onToast(data.message);
|
||||
|
||||
const { year, month, day } = $common.formatDateTime(new Date(date));
|
||||
const param = new URLSearchParams();
|
||||
param.append('year', year);
|
||||
param.append('month', month);
|
||||
param.append('day', day);
|
||||
|
||||
await fetchEventList(param);
|
||||
useFilterEventList(month, day);
|
||||
};
|
||||
|
||||
// 이벤트 선택 핸들러
|
||||
const handleEventSelect = data => {
|
||||
toggleEvent(data.date, data.code, data.title);
|
||||
showModal.value = false;
|
||||
};
|
||||
|
||||
// 이벤트 추가 핸들러
|
||||
const handleEventInsert = data => {
|
||||
insertEvent(data.date, data.code, data.title, data.place, data.time);
|
||||
showModal.value = false;
|
||||
};
|
||||
|
||||
// 이벤트 삭제 핸들러
|
||||
const handleEventDelete = data => {
|
||||
toggleEvent(data.date, data.code, data.title);
|
||||
showModal.value = false;
|
||||
};
|
||||
|
||||
// 이벤트 모달 닫기
|
||||
const handleCloseModal = () => {
|
||||
showModal.value = false;
|
||||
};
|
||||
|
||||
// 달력 이벤트 아이콘 표시 함수
|
||||
const handleEventContent = item => {
|
||||
if (!item.event) return null;
|
||||
|
||||
// 공휴일인 경우 텍스트로 표시
|
||||
if (item.event.classNames?.includes('holiday-event')) {
|
||||
return {
|
||||
html: `<div class="holiday-text" style="color: white;">${item.event.title}</div>`,
|
||||
};
|
||||
}
|
||||
|
||||
// 현재 이벤트의 타입만 확인
|
||||
const eventType = item.event.extendedProps.type;
|
||||
if (!eventType) return null;
|
||||
|
||||
let iconCode = '';
|
||||
switch (eventType) {
|
||||
case 'birthday':
|
||||
iconCode = '300201';
|
||||
break;
|
||||
case 'vacation':
|
||||
iconCode = '300202';
|
||||
break;
|
||||
case 'birthdayParty':
|
||||
iconCode = '300203';
|
||||
break;
|
||||
case 'dinner':
|
||||
iconCode = '300204';
|
||||
break;
|
||||
case 'teaTime':
|
||||
iconCode = '300205';
|
||||
break;
|
||||
case 'workshop':
|
||||
iconCode = '300206';
|
||||
break;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
html: `<img src="${baseUrl}img/main-category-img/main-${iconCode}.png" class="calendar-event-icon" style="width: 20px; height: 20px; margin: 2px;" />`,
|
||||
};
|
||||
};
|
||||
|
||||
// 캘린더 옵션 설정
|
||||
const calendarOptions = reactive({
|
||||
plugins: [dayGridPlugin, interactionPlugin],
|
||||
initialView: 'dayGridMonth',
|
||||
headerToolbar: {
|
||||
left: 'today',
|
||||
center: 'title',
|
||||
right: 'prev,next',
|
||||
},
|
||||
locale: 'kr',
|
||||
events: calendarEvents,
|
||||
eventOrder: 'sortIdx',
|
||||
contentHeight: 'auto',
|
||||
eventContent: handleEventContent,
|
||||
selectable: true,
|
||||
selectAllow: selectInfo => isSelectableDate(selectInfo.start),
|
||||
dateClick: handleDateClick,
|
||||
dayCellDidMount: arg => {
|
||||
// 날씨 정보 업데이트
|
||||
addWeatherInfo(arg);
|
||||
const dateCell = arg.el;
|
||||
|
||||
// 마우스 홀드시 이벤트 모달
|
||||
dateCell.addEventListener('mousedown', e => {
|
||||
if (!isSelectableDate(arg.date)) return; // 공휴일 제외
|
||||
const date = $common.dateFormatter(arg.date, 'YMD');
|
||||
handleMouseDown(date, e);
|
||||
});
|
||||
dateCell.addEventListener('mouseup', handleMouseUp);
|
||||
dateCell.addEventListener('mouseleave', handleMouseUp);
|
||||
},
|
||||
dayCellClassNames: getCellClassNames,
|
||||
unselectAuto: true,
|
||||
droppable: false,
|
||||
eventDisplay: 'block',
|
||||
customButtons: {
|
||||
prev: {
|
||||
text: 'PREV',
|
||||
click: () => moveCalendar(1),
|
||||
},
|
||||
today: {
|
||||
text: 'TODAY',
|
||||
click: () => moveCalendar(3),
|
||||
},
|
||||
next: {
|
||||
text: 'NEXT',
|
||||
click: () => moveCalendar(2),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 날짜 정보 업데이트
|
||||
const addWeatherInfo = arg => {
|
||||
const dateStr = $common.dateFormatter(arg.date, 'YMD');
|
||||
// 해당 셀의 날짜와 일치하는 데이터
|
||||
const theDayWeatherInfo = dailyWeatherList.value.find(weather => weather.date === dateStr);
|
||||
const dayTopEl = arg.el.querySelector('.fc-daygrid-day-top');
|
||||
const isWeatherInfoExist = dayTopEl.getElementsByClassName('weather-icon').length > 0; // 중복 방지
|
||||
|
||||
if (theDayWeatherInfo && !isWeatherInfoExist) {
|
||||
let weatherIconUrl = `https://openweathermap.org/img/wn/${theDayWeatherInfo.icon}.png`;
|
||||
if (theDayWeatherInfo.icon === '01d' || theDayWeatherInfo.icon === '01n') {
|
||||
weatherIconUrl = '/img/icons/sunny-custom.png';
|
||||
}
|
||||
// 날씨 이미지 세팅
|
||||
const weatherEl = document.createElement('img');
|
||||
weatherEl.src = weatherIconUrl;
|
||||
weatherEl.alt = theDayWeatherInfo.description;
|
||||
weatherEl.className = 'weather-icon';
|
||||
weatherEl.style.width = '28px';
|
||||
weatherEl.style.height = '28px';
|
||||
|
||||
// 해당 셀에 이미지 넣기
|
||||
dayTopEl.classList.add('align-items-center');
|
||||
dayTopEl.prepend(weatherEl); // 이상하게 가장 앞에 넣어야 일자 뒤에 나옴 reverse 옵션이 있는듯
|
||||
}
|
||||
};
|
||||
|
||||
// 날씨 데이터 변경 감지하여 날씨 정보 업데이트
|
||||
watch(dailyWeatherList, async () => {
|
||||
await nextTick(); // DOM이 업데이트된 후 실행
|
||||
document.querySelectorAll('.fc-daygrid-day').forEach(dayCell => {
|
||||
addWeatherInfo({
|
||||
el: dayCell,
|
||||
date: dayCell.dataset.date,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const handleWheelEvent = e => {
|
||||
handleCloseModal();
|
||||
};
|
||||
|
||||
// 선택한 날의 이벤트 중에 휴가자 항목을 누를때 휴가페이지 이동
|
||||
const handleClickVacation = () => {
|
||||
router.push('/vacation');
|
||||
};
|
||||
|
||||
// 달력 뷰 변경 감지 (월 변경 시 데이터 다시 가져오기)
|
||||
watch(
|
||||
() => fullCalendarRef.value?.getApi().currentData.viewTitle,
|
||||
async () => {
|
||||
await fetchData();
|
||||
},
|
||||
);
|
||||
|
||||
// selectbox 프로젝트 선택 변경 사항 감지
|
||||
watch(
|
||||
() => projectStore.selectedProject,
|
||||
newProject => {
|
||||
if (newProject) {
|
||||
selectedProject.value = newProject.PROJCTSEQ;
|
||||
checkedInProject.value = newProject;
|
||||
} else {
|
||||
selectedProject.value = null;
|
||||
checkedInProject.value = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const handleLeaveTimeUpdate = async event => {
|
||||
const memberSeq = user.value.id;
|
||||
if (!memberSeq) return;
|
||||
|
||||
// 현 사용자 프로젝트 퇴근기록 확인
|
||||
const { data } = await $api.post('main/getUserLeaveRecord', {
|
||||
memberSeq: memberSeq,
|
||||
});
|
||||
|
||||
const res = data?.data;
|
||||
if (res && !res?.COMMUTLVE) {
|
||||
await projectStore.getMemberProjects();
|
||||
|
||||
if (projectStore.activeMemberProjectList.length > 0) {
|
||||
const previousProject =
|
||||
projectStore.activeMemberProjectList.find(p => res.MEMBERSEQ === user.value.id && res.PROJCTLVE === p.PROJCTSEQ) ||
|
||||
projectStore.activeMemberProjectList[0]; // 이전 출근 프로젝트가 없으면 첫 번째 프로젝트
|
||||
|
||||
if (previousProject) {
|
||||
selectedProject.value = previousProject.PROJCTSEQ;
|
||||
projectStore.setSelectedProject(previousProject);
|
||||
} else if (projectStore.activeProjectList.length > 0) {
|
||||
selectedProject.value = projectStore.activeProjectList[0].PROJCTSEQ;
|
||||
projectStore.setSelectedProject(projectStore.activeProjectList[0]);
|
||||
} else {
|
||||
selectedProject.value = null;
|
||||
projectStore.setSelectedProject(null);
|
||||
}
|
||||
} else {
|
||||
selectedProject.value = null;
|
||||
projectStore.setSelectedProject(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await userStore.userInfo();
|
||||
user.value = userStore.user;
|
||||
|
||||
// 저장된 선택 프로젝트 가져오기
|
||||
const storedProject = projectStore.getSelectedProject();
|
||||
if (storedProject) {
|
||||
selectedProject.value = storedProject.PROJCTSEQ;
|
||||
checkedInProject.value = storedProject;
|
||||
}
|
||||
|
||||
// 오늘 기준 데이터 호출
|
||||
const { year, month, day } = $common.getToday();
|
||||
const param = new URLSearchParams();
|
||||
param.append('year', year);
|
||||
param.append('month', month);
|
||||
param.append('day', day);
|
||||
|
||||
// 이벤트 카테고리 호출
|
||||
await fetchCategoryList();
|
||||
await fetchEventList(param);
|
||||
useFilterEventList(month, day);
|
||||
|
||||
// 스크롤 감지 이벤트 리스너
|
||||
window.addEventListener('wheel', handleWheelEvent);
|
||||
window.addEventListener('click', colorToday);
|
||||
|
||||
datePickerStore.initDatePicker(fullCalendarRef, async (year, month, options) => {
|
||||
// 데이터 다시 불러오기
|
||||
await fetchData();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
::v-deep(.fc-h-event) {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
::v-deep(.event-modal) {
|
||||
padding: 8px;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
::v-deep(.event-icon-select:hover) {
|
||||
transform: scale(1.1);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
/* 이벤트 모달 노출 시 텍스트 선택 방지 */
|
||||
::v-deep(.fc-daygrid-day) {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
::v-deep(.fc-daygrid-day-events) {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
/* align-content: flex-start;
|
||||
align-items: center;
|
||||
text-align: center !important; */
|
||||
}
|
||||
|
||||
/* 공휴일만 가로로 넗게 나오게 */
|
||||
::v-deep(.fc-daygrid-event-harness:has(.holiday-event)) {
|
||||
width: 100% !important;
|
||||
}
|
||||
</style>
|
||||
@ -1,137 +0,0 @@
|
||||
<template>
|
||||
<div class="">
|
||||
<template v-for="category in categoryList" :key="category.CMNCODVAL">
|
||||
<div
|
||||
v-if="
|
||||
(category.CMNCODVAL === 300201 && birthdayList?.length) ||
|
||||
(category.CMNCODVAL === 300202 && vacationList?.length) ||
|
||||
(category.CMNCODVAL === 300203 && birthdayPartyList?.length) ||
|
||||
(category.CMNCODVAL === 300204 && dinnerList?.length) ||
|
||||
(category.CMNCODVAL === 300205 && teaTimeList?.length) ||
|
||||
(category.CMNCODVAL === 300206 && workShopList?.length)
|
||||
"
|
||||
@click="category.CMNCODVAL == 300202 ? $emit('handleClickVacation') : ''"
|
||||
:class="category.CMNCODVAL == 300202 ? 'pointer' : ''"
|
||||
class="border border-2 mt-3 card p-2"
|
||||
>
|
||||
<div class="row g-2 position-relative">
|
||||
<div class="col-3 mx-0 px-0">
|
||||
<div class="ratio ratio-1x1">
|
||||
<img
|
||||
:src="`${baseUrl}img/main-category-img/main-${category.CMNCODVAL}.png`"
|
||||
:alt="`${category.CMNCODNAM}`"
|
||||
@error="$event.target.src = '/img/icons/icon.png'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-9 mx-0 px-0 d-flex align-items-center">
|
||||
<template v-if="category.CMNCODVAL === 300201">
|
||||
<MainMemberProfile :members="birthdayList" :baseUrl="baseUrl" />
|
||||
</template>
|
||||
<template v-if="category.CMNCODVAL === 300202">
|
||||
<MainMemberProfile :members="vacationList" :baseUrl="baseUrl" />
|
||||
</template>
|
||||
<template v-if="category.CMNCODVAL === 300203">
|
||||
<MainEventBoard :place="birthdayPartyList[0].LOCEVTPLC" :time="birthdayPartyList[0].LOCEVTTME" />
|
||||
</template>
|
||||
<template v-if="category.CMNCODVAL === 300204">
|
||||
<MainEventBoard :place="dinnerList[0].LOCEVTPLC" :time="dinnerList[0].LOCEVTTME" />
|
||||
</template>
|
||||
<template v-if="category.CMNCODVAL === 300205">
|
||||
<MainEventBoard :place="teaTimeList[0].LOCEVTPLC" :time="teaTimeList[0].LOCEVTTME" />
|
||||
</template>
|
||||
<template v-if="category.CMNCODVAL === 300206">
|
||||
<MainEventBoard :place="workShopList[0].LOCEVTPLC" :time="workShopList[0].LOCEVTTME" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineEmits } from 'vue';
|
||||
import MainMemberProfile from '@c/main/MainMemberProfile.vue';
|
||||
import MainEventBoard from '@c/main/MainEventBoard.vue';
|
||||
|
||||
const props = defineProps({
|
||||
project: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
categoryList: {
|
||||
type: Array,
|
||||
},
|
||||
commuters: {
|
||||
type: Array,
|
||||
required: false,
|
||||
},
|
||||
baseUrl: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
user: {
|
||||
type: Object,
|
||||
required: false,
|
||||
},
|
||||
selectedProject: {
|
||||
type: Number,
|
||||
default: null,
|
||||
},
|
||||
checkedInProject: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
birthdayList: {
|
||||
type: Array,
|
||||
},
|
||||
vacationList: {
|
||||
type: Array,
|
||||
},
|
||||
birthdayPartyList: {
|
||||
type: Array,
|
||||
},
|
||||
dinnerList: {
|
||||
type: Array,
|
||||
},
|
||||
teaTimeList: {
|
||||
type: Array,
|
||||
},
|
||||
workShopList: {
|
||||
type: Array,
|
||||
},
|
||||
});
|
||||
|
||||
defineEmits(['handleClickVacation']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.event-board {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.event-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
min-width: 3rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@ -1,32 +0,0 @@
|
||||
<template>
|
||||
<div class="ms-2" style="flex: 1">
|
||||
<ul class="row gx-1 mb-0 list-inline">
|
||||
<li class="col-4 me-0" v-for="(member, index) in members" :key="index">
|
||||
<div class="ratio ratio-1x1 mb-0">
|
||||
<img
|
||||
:src="`${profileImgUrl}profile/${member.MEMBERPRF}`"
|
||||
:style="`border-color: ${member.usercolor} !important;`"
|
||||
alt="User Profile"
|
||||
class="rounded-circle border border-2 profile-img"
|
||||
@error="$event.target.src = '/img/icons/icon.png'"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
members: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
baseUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const profileImgUrl = import.meta.env.VITE_SERVER_IMG_URL;
|
||||
</script>
|
||||
@ -1,244 +0,0 @@
|
||||
<template>
|
||||
<div class="col-md-6 col-lg-4 col-xl-4 order-0 mb-6">
|
||||
<div class="card h-100">
|
||||
<!-- 더보기 버튼-->
|
||||
<div class="d-flex">
|
||||
<router-link
|
||||
to="/voteboard"
|
||||
class="btn btn-primary mr-1 pe-1 ps-1 ms-auto my-auto h-50"
|
||||
>
|
||||
more
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<div class="card-title mb-0">
|
||||
<h5 class="mb-1 me-2">투표진행</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" v-if="voteListData.length > 0">
|
||||
<ul class="p-0 m-0">
|
||||
<li class="d-flex mb-1" v-for="item in voteListData" :key="item.LOCVOTSEQ">
|
||||
<div class="d-flex w-100 flex-wrap align-items-center justify-content-between gap-2">
|
||||
<div class="me-2 mb-3">
|
||||
<div class="text-muted small">{{ item.localVote.formatted_LOCVOTRDT }}</div>
|
||||
<div class="d-flex flex-wrap align-items-center">
|
||||
<div class="avatar flex-shrink-0 me-1">
|
||||
<img
|
||||
style="cursor: auto;"
|
||||
class="rounded-circle user-avatar object-fit-cover"
|
||||
:src="getProfileImage(item.localVote.MEMBERPRF)"
|
||||
alt="최초 작성자"
|
||||
:style="{ borderColor: item.localVote.usercolor }"
|
||||
@error="setDefaultImage"
|
||||
/>
|
||||
</div>
|
||||
<div class="timeline-event ps-1" style="cursor: pointer;" @click.stop="openModal(item.localVote.LOCVOTSEQ)" >
|
||||
<div class="timeline-header ">
|
||||
<small ><strong>{{ truncateTitle(item.localVote.LOCVOTTTL) }}</strong></small>
|
||||
</div>
|
||||
<small class="d-flex align-items-center lh-1 me-4 mb-4 mb-sm-0"
|
||||
:style="{ color: getDaysAgo(item.localVote.formatted_LOCVOTEDT) == '금일 종료' ? 'red' : '' }">
|
||||
⏰{{getDaysAgo(item.localVote.formatted_LOCVOTEDT)}}({{item.localVote.total_voted}}/{{ item.localVote.total_votable }})
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body" v-else>
|
||||
진행중인 투표가 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!--투표 모달 -->
|
||||
<CenterModal :display="isModalOpen" @close="closeModal">
|
||||
<template #title> 투표 하기 </template>
|
||||
<template #body>
|
||||
<div>
|
||||
<vote-list
|
||||
:key="voteListKey"
|
||||
:data="selectVoteDate"
|
||||
@checkedNames="checkedNames"
|
||||
@addContents="addContents"
|
||||
@endVoteId="endVoteId"
|
||||
@voteDelete="voteDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<BackButton @click="closeModal" />
|
||||
</template>
|
||||
</CenterModal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import router from '@/router';
|
||||
import $api from '@api';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import CenterModal from '@c/modal/CenterModal.vue';
|
||||
import BackButton from '@c/button/BackBtn.vue';
|
||||
import voteList from '@c/voteboard/voteCardList.vue';
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
|
||||
const toastStore = useToastStore();
|
||||
const currentPage = ref(1);
|
||||
const voteset = ref(0);
|
||||
const voteListData= ref([]);
|
||||
const voteListKey = ref(0); //초기화
|
||||
// 로그 모달 상태
|
||||
const isModalOpen = ref(false);
|
||||
const selectVoteDate = ref([]);
|
||||
// 로그 모달 열기
|
||||
const openModal = async (id) => {
|
||||
isModalOpen.value = true;
|
||||
if(id){
|
||||
const selectData = voteListData.value.filter((item) => item.localVote.LOCVOTSEQ === id);
|
||||
selectVoteDate.value = selectData;
|
||||
}
|
||||
};
|
||||
// 로그 모달 닫기
|
||||
const closeModal = () => {
|
||||
isModalOpen.value = false;
|
||||
voteListKey.value++;
|
||||
};
|
||||
// 프로필 이미지
|
||||
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
|
||||
const defaultProfile = "/img/icons/icon.png";
|
||||
const getProfileImage = (profilePath) => {
|
||||
return profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile;
|
||||
};
|
||||
const setDefaultImage = (event) => {
|
||||
event.target.src = defaultProfile;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
getvoteList();
|
||||
});
|
||||
|
||||
//투표목록
|
||||
const getvoteList = () => {
|
||||
$api.get('vote/getVoteList',{
|
||||
params:
|
||||
{
|
||||
page: 1
|
||||
,voteset:'2' //투표중
|
||||
,myVote:'2' //내가 안한 투표
|
||||
}
|
||||
}).then(res => {
|
||||
voteListData.value = res.data.data.list;
|
||||
voteListData.value = res.data.data.list.slice(0, 6);
|
||||
})
|
||||
};
|
||||
|
||||
//투표하기
|
||||
const checkedNames = (numList) => {
|
||||
$api.post('vote/insertCheckedNums',{
|
||||
checkedList :numList
|
||||
,votenum : numList[0].LOCVOTSEQ
|
||||
}).then((res)=>{
|
||||
if(res.data.status === 'OK'){
|
||||
toastStore.onToast('투표가 완료되었습니다.', 's');
|
||||
isModalOpen.value = false;
|
||||
getvoteList();
|
||||
voteListKey.value++;
|
||||
}
|
||||
})
|
||||
}
|
||||
//투표항목추가
|
||||
const addContents = (itemList, voteId) => {
|
||||
$api.post('vote/insertWord',{
|
||||
itemList :itemList
|
||||
,voteId :voteId
|
||||
}).then((res)=>{
|
||||
if(res.data.status === 'OK'){
|
||||
toastStore.onToast('항목이 등록되었습니다.', 's');
|
||||
getvoteList();
|
||||
const updatedVote = selectVoteDate.value.find(vote => vote.localVote.LOCVOTSEQ === voteId);
|
||||
if (updatedVote) {
|
||||
if (!updatedVote.voteDetails) {
|
||||
updatedVote.voteDetails = [];
|
||||
}
|
||||
const maxSeq = updatedVote.voteDetails.reduce((max, item) => {
|
||||
return item.VOTDETSEQ > max ? item.VOTDETSEQ : max;
|
||||
}, 0);
|
||||
// 새 항목을 voteDetails에 추가
|
||||
itemList.forEach(item => {
|
||||
updatedVote.voteDetails.push({
|
||||
VOTDETSEQ: maxSeq + 1,
|
||||
LOCVOTSEQ: voteId,
|
||||
LOCVOTCON: item.content,
|
||||
LOCVOTLIK: item.url,
|
||||
VOTE_COUNT: 0,
|
||||
yesvote: 0
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
//투표종료
|
||||
const endVoteId = (endVoteId) => {
|
||||
if(confirm('투표를 종료하시겠습니까?')){
|
||||
$api.patch('vote/updateEndData',{
|
||||
endVoteId :endVoteId
|
||||
}).then((res)=>{
|
||||
if(res.data.status === 'OK'){
|
||||
getvoteList();
|
||||
isModalOpen.value = false;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
//투표 삭제
|
||||
const voteDelete =(id) =>{
|
||||
if(confirm('투표를 삭제하시겠습니까?')){
|
||||
$api.patch('vote/updateDeleteData',{
|
||||
deleteVoteId :id
|
||||
}).then((res)=>{
|
||||
if(res.data.status === 'OK'){
|
||||
toastStore.onToast('투표가 삭제되었습니다.', 's');
|
||||
getvoteList();
|
||||
isModalOpen.value = false;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// 제목이 14글자 넘어가면 ... 처리하는 함수
|
||||
const truncateTitle = title => {
|
||||
return title.length > 10 ? title.slice(0, 10) + '...' : title;
|
||||
};
|
||||
|
||||
//투표이동
|
||||
const goVoteList = () =>{
|
||||
router.push({
|
||||
path: '/voteboard',
|
||||
query: {
|
||||
voteset: '2' //투표중
|
||||
,myVote:'2' //내가 안한 투표
|
||||
,id:id
|
||||
}
|
||||
});
|
||||
}
|
||||
//종료 ㅇㅇ 일 전
|
||||
const getDaysAgo = (dateString) => {
|
||||
const inputDate = new Date(dateString); // 문자열을 Date 객체로 변환
|
||||
const today = new Date(); // 현재 날짜 가져오기
|
||||
const input = new Date(inputDate.getFullYear(), inputDate.getMonth(), inputDate.getDate());
|
||||
const now = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
const timeDiff = now - input;
|
||||
const dayDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
|
||||
// 오늘 날짜인 경우
|
||||
if (dayDiff === 0) return "금일 종료";
|
||||
|
||||
return `종료 ${Math.abs(dayDiff)}일 전`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-avatar {
|
||||
border: 3px solid;
|
||||
}
|
||||
</style>
|
||||
@ -1,139 +0,0 @@
|
||||
<template>
|
||||
<div class="col-md-6 col-lg-4 col-xl-4 order-0 mb-6">
|
||||
<div class="card h-100">
|
||||
<!-- 더보기 버튼 -->
|
||||
<div class="d-flex ">
|
||||
<router-link
|
||||
to="/wordDict"
|
||||
class="btn btn-primary mr-1 pe-1 ps-1 ms-auto my-auto h-50"
|
||||
>
|
||||
more
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="card-header d-flex justify-content-between">
|
||||
<div class="card-title mb-0">
|
||||
<h5 class="mb-1 me-2">용어집</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" v-if="wordList.length > 0">
|
||||
<ul class="p-0 m-0" v-for="item in wordList" :key="item.WRDDICSEQ">
|
||||
<li class="d-flex align-items-center mb-1">
|
||||
<!-- 프로필 이미지 -->
|
||||
<div class="avatar flex-shrink-0 me-2 d-flex align-items-center">
|
||||
<img
|
||||
class="rounded-circle user-avatar object-fit-cover"
|
||||
:src="getProfileImage(item.lastEditor.profileImage)"
|
||||
alt="최종 작성자"
|
||||
:style="{ borderColor: item.lastEditor.color }"
|
||||
@error="setDefaultImage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 텍스트 영역 -->
|
||||
<div class="timeline-event ps-1 flex-grow-1" style="cursor: pointer;" @click="goWordList(item.WRDDICCAT,item.WRDDICTTL)">
|
||||
<div class="timeline-header">
|
||||
<small class="text-primary text-uppercase">{{ item.category }}</small>
|
||||
</div>
|
||||
<h6 class="my-50 d-flex align-items-center">
|
||||
{{ truncateTitle(item.WRDDICTTL) }}
|
||||
</h6>
|
||||
<div class="text-muted small">{{$common.dateFormatter(item.lastEditor.updatedAt)}}</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body" v-else >
|
||||
등록된 용어가 없습니다.
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import axios from '@api';
|
||||
import { getCurrentInstance, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import commonApi from '@/common/commonApi';
|
||||
|
||||
const { appContext } = getCurrentInstance();
|
||||
const $common = appContext.config.globalProperties.$common;
|
||||
const router = useRouter();
|
||||
|
||||
onMounted(() => {
|
||||
getwordList();
|
||||
});
|
||||
|
||||
// 프로필 이미지
|
||||
const baseUrl = axios.defaults.baseURL.replace(/api\/$/, '');
|
||||
const defaultProfile = "/img/icons/icon.png";
|
||||
const getProfileImage = (profilePath) => {
|
||||
return profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile;
|
||||
};
|
||||
const setDefaultImage = (event) => {
|
||||
event.target.src = defaultProfile;
|
||||
};
|
||||
const wordList = ref([]);
|
||||
//용어 목록
|
||||
const getwordList = (searchKeyword='', indexKeyword='', category='') => {
|
||||
axios.get('worddict/getWordList',{
|
||||
params: {
|
||||
searchKeyword : searchKeyword,
|
||||
indexKeyword :indexKeyword,
|
||||
category : category,
|
||||
pageNum:6
|
||||
}
|
||||
})
|
||||
.then(res => {
|
||||
wordList.value = res.data.data.data;
|
||||
});
|
||||
};
|
||||
//초성 /알파벳 변환
|
||||
const getFirstCharacter = (char) => {
|
||||
const CHOSUNG_LIST = [
|
||||
'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ',
|
||||
'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
|
||||
];
|
||||
|
||||
if (!char || char.length === 0) return '';
|
||||
|
||||
const code = char.charCodeAt(0);
|
||||
|
||||
// 한글 범위 (가~힣) → 초성 변환
|
||||
if (code >= 0xAC00 && code <= 0xD7A3) {
|
||||
const index = Math.floor((code - 0xAC00) / (21 * 28));
|
||||
return CHOSUNG_LIST[index];
|
||||
}
|
||||
|
||||
// 영어 소문자 → 대문자로 변환
|
||||
if (char.match(/[a-zA-Z]/)) {
|
||||
return char.toLowerCase();
|
||||
}
|
||||
|
||||
// 기타 문자 (숫자, 특수문자) 그대로 반환
|
||||
return char;
|
||||
};
|
||||
//용어집 이동
|
||||
const goWordList = (category, indexKeyword) => {
|
||||
const firstChar = getFirstCharacter(indexKeyword[0]); // 첫 글자 변환
|
||||
router.push({
|
||||
path: '/wordDict',
|
||||
query: {
|
||||
indexKeyword: firstChar,
|
||||
category: category,
|
||||
}
|
||||
});
|
||||
};
|
||||
// 제목이 14글자 넘어가면 ... 처리하는 함수
|
||||
const truncateTitle = title => {
|
||||
return title.length > 25 ? title.slice(0, 25) + '...' : title;
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.user-avatar {
|
||||
border: 3px solid;
|
||||
}
|
||||
</style>
|
||||
@ -1,120 +0,0 @@
|
||||
<template>
|
||||
<div class="row g-4 mt-2" v-if="memberList?.length">
|
||||
<div>
|
||||
<div class="card">
|
||||
<div class="card-body p-3 row">
|
||||
<div class="d-flex justify-content-between mb-2">
|
||||
<h5 class="card-title fw-bold">사원 등록 관리</h5>
|
||||
</div>
|
||||
<div class="g-4 col-12 col-lg-6" v-for="member in memberList" :key="member.MEMBERSEQ">
|
||||
<div class="card">
|
||||
<div class="row card-body">
|
||||
<div class="col-3 d-flex align-items-center">
|
||||
<img
|
||||
:src="`${imgURL}profile/${member.MEMBERPRF}`"
|
||||
alt="Profile Image"
|
||||
class="img-thumbnail mx-auto d-block"
|
||||
style="max-height: 140px"
|
||||
@error="$event.target.src = '/img/icons/icon.png'"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<div class="d-flex flex-sm-row align-items-center pb-2">
|
||||
<div class="font-bold">{{ member.MEMBERNAM }}</div>
|
||||
<div class="ms-2">
|
||||
({{ member.MBTI }})
|
||||
<img
|
||||
role="img"
|
||||
class="w-px-30 h-px-40"
|
||||
:src="`/img/mbti/${member.MBTI.toLowerCase()}.png`"
|
||||
alt="MBTI image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-sm-row align-items-center pb-2">
|
||||
<i class="bx bx-id-card"></i>
|
||||
<div class="ms-2">{{ member.MEMBERIDS }}</div>
|
||||
</div>
|
||||
<div class="d-flex flex-sm-row align-items-center pb-2">
|
||||
<i class="bx bxs-phone"></i>
|
||||
<div class="ms-2">{{ $common.addHyphenToPhoneNumber(member.MEMBERTEL) }}</div>
|
||||
</div>
|
||||
<div class="d-flex flex-sm-row align-items-center pb-2">
|
||||
<i class="bx bx-calendar"></i>
|
||||
<div class="ms-2">{{ $common.dateFormatter(member.MEMBERCDT) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-2 d-flex align-items-center">
|
||||
<div>
|
||||
<div>
|
||||
<label class="switch"
|
||||
><input
|
||||
type="checkbox"
|
||||
:checked="member.checked"
|
||||
@click="handleRegisterMember($event, member)" />
|
||||
<span class="slider round"></span
|
||||
></label>
|
||||
</div>
|
||||
<button
|
||||
class="btn-close btn-close-sm"
|
||||
style="position: absolute; top: 10px; right: 10px"
|
||||
@click="handleRejectMember(member)"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
import $api from '@api';
|
||||
|
||||
const memberList = ref([]);
|
||||
const toast = useToastStore();
|
||||
const imgURL = import.meta.env.VITE_SERVER_IMG_URL;
|
||||
|
||||
// 조회 api
|
||||
const fetchRegisterMemberList = async () => {
|
||||
const { data } = await $api.get('main/registerMemberList');
|
||||
if (data?.data) {
|
||||
memberList.value = data.data.map(member => ({
|
||||
...member,
|
||||
checked: false, // 각 항목에 checked 값 설정
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 사원 등록 api
|
||||
const handleRegisterMember = async (e, member) => {
|
||||
e.preventDefault();
|
||||
|
||||
const { data } = await $api.post('main/registerMember', { memberSeq: member.MEMBERSEQ });
|
||||
if (data?.data) {
|
||||
member.checked = true;
|
||||
toast.onToast(data.data, 's');
|
||||
fetchRegisterMemberList();
|
||||
}
|
||||
};
|
||||
|
||||
// 사원 등록 미승인 api
|
||||
const handleRejectMember = async member => {
|
||||
if (!confirm('해당 사원 등록을 거절하시겠습니까?')) return;
|
||||
|
||||
const { data } = await $api.post('main/rejectMember', { memberSeq: member.MEMBERSEQ });
|
||||
if (data?.data) {
|
||||
toast.onToast(data.data, 's');
|
||||
fetchRegisterMemberList();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchRegisterMemberList();
|
||||
});
|
||||
</script>
|
||||
@ -1,114 +0,0 @@
|
||||
<template>
|
||||
<div class="position-relative">
|
||||
<div @click="togglePopover">
|
||||
<slot name="trigger">
|
||||
<i class="bx bxs-map"></i>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isVisible"
|
||||
class="position-absolute map z-3"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close popover-close"
|
||||
@click="togglePopover"
|
||||
></button>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-1">
|
||||
<KakaoMap
|
||||
v-if="coordinates"
|
||||
:lat="coordinates.lat"
|
||||
:lng="coordinates.lng"
|
||||
class="w-px-250 h-px-200"
|
||||
@onLoadKakaoMap="onLoadKakaoMap"
|
||||
>
|
||||
<KakaoMapMarker
|
||||
:lat="coordinates.lat"
|
||||
:lng="coordinates.lng"
|
||||
/>
|
||||
</KakaoMap>
|
||||
|
||||
<div class="position-absolute top-50 translate-middle-y end-0 me-3 z-1 d-flex flex-column gap-1">
|
||||
<button class="btn-secondary border-none" @click="zoomOut">+</button>
|
||||
<button class="btn-secondary border-none" @click="zoomIn">-</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { KakaoMap, KakaoMapMarker } from 'vue3-kakao-maps';
|
||||
|
||||
const props = defineProps({
|
||||
address: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
isVisible: {
|
||||
type: Boolean,
|
||||
required: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update-visible']);
|
||||
|
||||
const coordinates = ref(null);
|
||||
const map = ref(null);
|
||||
|
||||
// 주소를 좌표로 변환하는 함수
|
||||
const convertAddressToCoordinates = () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!window.kakao || !window.kakao.maps) {
|
||||
reject(new Error('Kakao Maps not loaded'));
|
||||
return;
|
||||
}
|
||||
|
||||
const geocoder = new window.kakao.maps.services.Geocoder();
|
||||
geocoder.addressSearch(props.address, (result, status) => {
|
||||
if (status === window.kakao.maps.services.Status.OK) {
|
||||
resolve({
|
||||
lat: parseFloat(result[0].y),
|
||||
lng: parseFloat(result[0].x)
|
||||
});
|
||||
} else {
|
||||
reject(new Error('Address conversion failed'));
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const togglePopover = () => {
|
||||
emit('update-visible', !props.isVisible);
|
||||
};
|
||||
|
||||
const onLoadKakaoMap = (mapRef) => {
|
||||
map.value = mapRef;
|
||||
};
|
||||
|
||||
// 지도 확대
|
||||
const zoomIn = () => {
|
||||
if (map.value) {
|
||||
const level = map.value.getLevel();
|
||||
map.value.setLevel(level + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 지도 축소
|
||||
const zoomOut = () => {
|
||||
if (map.value) {
|
||||
const level = map.value.getLevel();
|
||||
map.value.setLevel(level - 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 컴포넌트 마운트 시 좌표 변환
|
||||
onMounted(async () => {
|
||||
coordinates.value = await convertAddressToCoordinates();
|
||||
});
|
||||
</script>
|
||||
@ -1,12 +1,13 @@
|
||||
|
||||
<template>
|
||||
<div @click="closeModal" class="modal fade scrollbar-none" :class="{ 'show': display , 'd-block': display , 'bg-dark bg-opacity-50' : display }" id="modalCenter" tabindex="-1" aria-modal="true" role="dialog">
|
||||
<div @click="closeModal" class="modal fade" :class="{ 'show': display, 'display-block': display , 'modal-back' : display }" id="modalCenter" tabindex="-1" aria-modal="true" role="dialog">
|
||||
<div @click.stop class="modal-dialog modal-dialog-centered" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title m-auto fw-bold" id="modalCenterTitle">
|
||||
<h5 class="modal-title" id="modalCenterTitle">
|
||||
<slot name="title">Modal Title</slot>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" @click="closeModal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<slot name="body">Modal body</slot>
|
||||
@ -20,31 +21,26 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
const prop = defineProps({
|
||||
display : {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: true,
|
||||
},
|
||||
create: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close' , 'reset']);
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const closeModal = () => {
|
||||
if (prop.create) {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
emit('close', false);
|
||||
emit('close' , false);
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
<style>
|
||||
.modal-back {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="toastStore.toastModal"
|
||||
:class="['bs-toast toast toast-placement-ex m-2 fade show', toastClass]"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
aria-atomic="true"
|
||||
>
|
||||
<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"
|
||||
@click="offToast"
|
||||
></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ toastStore.toastMsg }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
import { computed } from 'vue';
|
||||
const toastStore = useToastStore();
|
||||
|
||||
const offToast = () => {
|
||||
toastStore.offToast(); // 상태 변경으로 토스트 숨기기
|
||||
};
|
||||
|
||||
const toastClass = computed(() => {
|
||||
return toastStore.toastType === 'e' ? 'bg-danger' : 'bg-success'; // 에러일 경우 red, 정상일 경우 blue
|
||||
});
|
||||
</script>
|
||||
@ -1,125 +0,0 @@
|
||||
<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>
|
||||
|
||||
<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({
|
||||
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 myUserId = computed(() => userStore.user.id);
|
||||
const myRemainingQuota = computed(() => {
|
||||
return props.remainingVacationData?.[myUserId.value] ?? 0;
|
||||
});
|
||||
const isGiftButtonDisabled = computed(() => {
|
||||
return myRemainingQuota.value < 0;
|
||||
});
|
||||
// 사원 별 남은 보내기 개수
|
||||
const fetchSentVacationCount = async () => {
|
||||
try {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
// 개수 증가
|
||||
const increaseCount = () => {
|
||||
if (grantCount.value < availableQuota.value) {
|
||||
grantCount.value++;
|
||||
}
|
||||
};
|
||||
// 개수 감소
|
||||
const decreaseCount = () => {
|
||||
if (grantCount.value > 0) {
|
||||
grantCount.value--;
|
||||
}
|
||||
};
|
||||
|
||||
// 연차 선물하기
|
||||
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');
|
||||
}
|
||||
} catch (error) {
|
||||
toastStore.onToast(' 연차 선물 실패!!.', 'e');
|
||||
}
|
||||
};
|
||||
|
||||
// 모달 닫기기
|
||||
const closeModal = () => {
|
||||
emit("close");
|
||||
};
|
||||
|
||||
watch(() => props.isOpen, async (newVal) => {
|
||||
if (newVal && 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) {
|
||||
await fetchSentVacationCount();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
</style>
|
||||