Merge branch 'main' into project-list
This commit is contained in:
commit
deddbf7856
28
localhost-key.pem
Normal file
28
localhost-key.pem
Normal file
@ -0,0 +1,28 @@
|
||||
-----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
Normal file
BIN
localhost.p12
Normal file
Binary file not shown.
26
localhost.pem
Normal file
26
localhost.pem
Normal file
@ -0,0 +1,26 @@
|
||||
-----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-----
|
||||
2263
package-lock.json
generated
2263
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -30,6 +30,7 @@
|
||||
"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"
|
||||
|
||||
@ -5,30 +5,13 @@
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.notice-row td {
|
||||
/* 게시판리스트 */
|
||||
.bg-label-gray td {
|
||||
color: #DC3545 !important;
|
||||
}
|
||||
.notice-row {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.general-row {
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
.clickable-row {
|
||||
cursor: pointer;
|
||||
}
|
||||
.clickable-row:hover {
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
.new-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.2em 0.4em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
|
||||
/* 휴가*/
|
||||
/* 휴가 */
|
||||
.half-day-buttons {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
@ -3,7 +3,7 @@ import { useRoute } from 'vue-router';
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
|
||||
const $api = axios.create({
|
||||
baseURL: 'http://localhost:10325/api/',
|
||||
baseURL: 'https://192.168.0.251:10325/api/',
|
||||
timeout: 300000,
|
||||
withCredentials: true,
|
||||
});
|
||||
@ -37,7 +37,7 @@ $api.interceptors.response.use(
|
||||
},
|
||||
function (error) {
|
||||
const toastStore = useToastStore();
|
||||
const currentPage = error.config.headers['X-Page-Route'];
|
||||
//const currentPage = error.config.headers['X-Page-Route'];
|
||||
// 오류 응답 처리
|
||||
if (error.response) {
|
||||
switch (error.response.status) {
|
||||
|
||||
12
src/components/button/BackBtn.vue
Normal file
12
src/components/button/BackBtn.vue
Normal file
@ -0,0 +1,12 @@
|
||||
<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>
|
||||
23
src/components/button/SaveBtn.vue
Normal file
23
src/components/button/SaveBtn.vue
Normal file
@ -0,0 +1,23 @@
|
||||
<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>
|
||||
@ -40,26 +40,26 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
type: [String, Number],
|
||||
default: '0',
|
||||
require: false,
|
||||
},
|
||||
isAlert : {
|
||||
isAlert: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
},
|
||||
isLabel : {
|
||||
isLabel: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
required: false,
|
||||
},
|
||||
isRow : {
|
||||
isRow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
required: false,
|
||||
},
|
||||
isCommon : {
|
||||
isCommon: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
required: false,
|
||||
@ -69,11 +69,19 @@ const props = defineProps({
|
||||
const emit = defineEmits(['update:data']);
|
||||
const selectData = ref(props.value);
|
||||
|
||||
// data 변경 감지
|
||||
// props.value의 변경을 감지하는 watch 추가
|
||||
watch(() => props.value, (newValue) => {
|
||||
selectData.value = newValue;
|
||||
}, { immediate: true });
|
||||
|
||||
// data 변경 감지 수정
|
||||
watch(() => props.data, (newData) => {
|
||||
if (props.isCommon && newData.length > 0) {
|
||||
selectData.value = newData[0].value;
|
||||
emit('update:data', selectData.value);
|
||||
// value prop이 '0'(기본값)일 때만 첫번째 아이템 선택
|
||||
if (props.value === '0') {
|
||||
selectData.value = newData[0].value;
|
||||
emit('update:data', selectData.value);
|
||||
}
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
@ -81,4 +89,4 @@ watch(() => props.data, (newData) => {
|
||||
watch(selectData, (newValue) => {
|
||||
emit('update:data', newValue);
|
||||
});
|
||||
</script>
|
||||
</script>
|
||||
214
src/components/modal/VacationGrantModal.vue
Normal file
214
src/components/modal/VacationGrantModal.vue
Normal file
@ -0,0 +1,214 @@
|
||||
<template>
|
||||
<div v-if="isOpen" class="modal-dialog" @click.self="closeModal">
|
||||
<div class="modal-content">
|
||||
<h5 class="modal-title">To. {{ targetUser.MEMBERNAM }} 🎁</h5>
|
||||
<button class="close-btn" @click="closeModal">✖</button>
|
||||
|
||||
<div class="modal-body">
|
||||
<p>해당 직원에게 부여할 연차 개수를 선택하세요. (남은 개수: {{ availableQuota }}개)</p>
|
||||
|
||||
<div class="vacation-control">
|
||||
<button @click="decreaseCount" :disabled="grantCount <= 0" class="count-btn">-</button>
|
||||
<span class="grant-count">{{ grantCount }}</span>
|
||||
<button @click="increaseCount" :disabled="grantCount >= availableQuota" class="count-btn">+</button>
|
||||
</div>
|
||||
|
||||
<button class="gift-btn" @click="saveVacationGrant" :disabled="grantCount === 0">
|
||||
<i class="bx bx-gift"></i> <!-- 선물상자 아이콘 -->
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineProps, defineEmits, watch, onMounted } from "vue";
|
||||
import axios from "@api";
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: Boolean, // 모달 상태
|
||||
targetUser: Object, // 선택한 사용자 정보
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close", "updateVacation"]);
|
||||
const grantCount = ref(0);
|
||||
const maxQuota = 2; // 1년에 보낼 수 있는 최대 개수
|
||||
const sentCount = ref(0); // 현재까지 보낸 개수
|
||||
const availableQuota = ref(2); // 남은 개수
|
||||
|
||||
// ✅ 해당 사용자에게 이미 보낸 연차 개수 조회
|
||||
const fetchSentVacationCount = async () => {
|
||||
try {
|
||||
const payload = { receiverId: props.targetUser.MEMBERSEQ };
|
||||
|
||||
const response = await axios.get(`vacation/sent`,{ params: payload });
|
||||
console.log(response.data.data[0].count)
|
||||
sentCount.value = response.data.data[0].count || 0; // 이미 보낸 개수
|
||||
availableQuota.value = Math.max(maxQuota - sentCount.value, 0); // 남은 개수 (0 이하 방지)
|
||||
console.log(`✅ 보낸 개수: ${sentCount.value}, 남은 개수: ${availableQuota.value}`);
|
||||
} catch (error) {
|
||||
console.error("🚨 연차 전송 기록 조회 실패:", error);
|
||||
availableQuota.value = maxQuota;
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 연차 개수 증가
|
||||
const increaseCount = () => {
|
||||
if (grantCount.value < availableQuota.value) {
|
||||
grantCount.value++;
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 연차 개수 감소
|
||||
const decreaseCount = () => {
|
||||
if (grantCount.value > 0) {
|
||||
grantCount.value--;
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 연차 부여 저장 요청 (saveVacations API 호출)
|
||||
const saveVacationGrant = async () => {
|
||||
try {
|
||||
const payload = [
|
||||
{
|
||||
date: new Date().toISOString().split("T")[0], // 오늘 날짜
|
||||
type: "700103", // 연차 코드
|
||||
senderId: props.targetUser.senderId, // 보내는 사람 ID
|
||||
receiverId: props.targetUser.MEMBERSEQ, // 받는 사람 ID
|
||||
count: grantCount.value, // 부여 개수
|
||||
},
|
||||
];
|
||||
|
||||
const response = await axios.post("vacation/save", payload);
|
||||
|
||||
if (response.data && response.data.status === "OK") {
|
||||
alert("✅ 연차가 부여되었습니다.");
|
||||
await fetchSentVacationCount(); // ✅ 보낸 개수 업데이트
|
||||
emit("updateVacation"); // ✅ 연차 정보 갱신 요청
|
||||
closeModal();
|
||||
} else {
|
||||
alert("🚨 연차 추가 중 오류가 발생했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("🚨 연차 추가 실패:", error);
|
||||
alert("연차 추가에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 모달 닫기
|
||||
const closeModal = () => {
|
||||
emit("close");
|
||||
};
|
||||
|
||||
// ✅ 모달이 열릴 때 초기 값 설정 및 보낸 개수 조회
|
||||
watch(
|
||||
() => props.isOpen,
|
||||
async (newVal) => {
|
||||
if (newVal && props.targetUser && props.targetUser.MEMBERSEQ) {
|
||||
console.log("🟢 모달이 열렸습니다. 데이터를 로드합니다.");
|
||||
grantCount.value = 0; // 초기화
|
||||
await fetchSentVacationCount(); // 보낸 개수 불러오기
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ✅ targetUser가 변경될 때도 fetchSentVacationCount 호출
|
||||
watch(
|
||||
() => props.targetUser,
|
||||
async (newUser) => {
|
||||
if (newUser && newUser.MEMBERSEQ) {
|
||||
console.log(`🔄 새로운 대상(${newUser.name})이 선택되었습니다.`);
|
||||
await fetchSentVacationCount();
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
// ✅ 컴포넌트가 마운트될 때도 보낸 개수 불러오기
|
||||
onMounted(async () => {
|
||||
if (props.isOpen && props.targetUser && props.targetUser.MEMBERSEQ) {
|
||||
await fetchSentVacationCount();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
/* 모달 본문 */
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 12px;
|
||||
width: 400px;
|
||||
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.15);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 닫기 버튼 */
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 연차 개수 조정 버튼 */
|
||||
.vacation-control {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.count-btn {
|
||||
font-size: 18px;
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.count-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 개수 표시 */
|
||||
.grant-count {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 선물 아이콘 버튼 */
|
||||
.gift-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
background: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 10px 15px;
|
||||
margin-top: 15px;
|
||||
cursor: pointer;
|
||||
transition: 0.3s;
|
||||
}
|
||||
|
||||
.gift-btn:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.gift-btn:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
165
src/components/modal/VacationModal.vue
Normal file
165
src/components/modal/VacationModal.vue
Normal file
@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div v-if="isOpen" class="modal-dialog" @click.self="closeModal">
|
||||
<div class="modal-content modal-scroll">
|
||||
<h5 class="modal-title">📅 내 연차 사용 내역</h5>
|
||||
<button class="close-btn" @click="closeModal">✖</button>
|
||||
|
||||
<!-- 연차 사용 및 받은 연차 리스트 -->
|
||||
<div class="modal-body" v-if="mergedVacations.length > 0">
|
||||
<ol class="vacation-list">
|
||||
<li
|
||||
v-for="(vacation, index) in mergedVacations"
|
||||
:key="index"
|
||||
class="vacation-item"
|
||||
>
|
||||
<span v-if="vacation.type === 'used'" class="vacation-index">
|
||||
{{ totalUsedVacationCount - usedVacations.findIndex(v => v.date === vacation.date) }})
|
||||
</span>
|
||||
<span :class="vacation.type === 'used' ? 'minus-symbol' : 'plus-symbol'">
|
||||
{{ vacation.type === 'used' ? '-' : '+' }}
|
||||
</span>
|
||||
<span :style="{ color: userColors[vacation.senderId || vacation.receiverId] || '#000' }"
|
||||
class="vacation-date">{{ formatDate(vacation.date) }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- 연차 데이터 없음 -->
|
||||
<p v-if="mergedVacations.length === 0" class="no-data">
|
||||
🚫 사용한 연차가 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits, computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
isOpen: Boolean,
|
||||
myVacations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
receivedVacations: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
userColors: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
// ✅ 사용한 연차 개수
|
||||
const totalUsedVacationCount = computed(() => props.myVacations.length);
|
||||
|
||||
// ✅ 사용한 연차 + 받은 연차 통합 후 내림차순 정렬
|
||||
const usedVacations = computed(() => props.myVacations.map(v => ({ ...v, type: "used" })));
|
||||
const receivedVacations = computed(() => props.receivedVacations.map(v => ({ ...v, type: "received" })));
|
||||
|
||||
const mergedVacations = computed(() => {
|
||||
return [...usedVacations.value, ...receivedVacations.value].sort(
|
||||
(a, b) => new Date(b.date) - new Date(a.date)
|
||||
);
|
||||
});
|
||||
|
||||
// ✅ 날짜 형식 변환 (YYYY-MM-DD)
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toISOString().split("T")[0]; // YYYY-MM-DD 형식
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
emit("close");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 모달 스타일 */
|
||||
.modal-dialog {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 스크롤 가능한 모달 */
|
||||
.modal-content {
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
width: 75%;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 닫기 버튼 */
|
||||
.close-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* 리스트 기본 스타일 */
|
||||
.vacation-list {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
/* 리스트 아이템 */
|
||||
.vacation-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
/* 인덱스 (연차 사용 개수) */
|
||||
.vacation-index {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
margin-right: 8px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* "-" 빨간색 */
|
||||
.minus-symbol {
|
||||
color: red;
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* "+" 파란색 */
|
||||
.plus-symbol {
|
||||
color: blue;
|
||||
font-weight: bold;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* 날짜 스타일 */
|
||||
.vacation-date {
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 연차 데이터 없음 */
|
||||
.no-data {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: gray;
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@ -1,67 +1,77 @@
|
||||
<template>
|
||||
<div class="card-body d-flex justify-content-center">
|
||||
<ul class="list-unstyled d-flex align-items-center gap-7 mb-0 mt-2">
|
||||
<li
|
||||
v-for="(user, index) in sortedUserList"
|
||||
:key="index"
|
||||
:class="{ disabled: user.disabled }"
|
||||
@click="toggleDisable(index)"
|
||||
data-bs-placement="top"
|
||||
:aria-label="user.MEMBERSEQ"
|
||||
>
|
||||
<img
|
||||
class="rounded-circle user-avatar"
|
||||
:src="getUserProfileImage(user.MEMBERPRF)"
|
||||
alt="user"
|
||||
:style="getDynamicStyle(user)"
|
||||
@error="setDefaultImage"
|
||||
@load="showImage"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<ul class="list-unstyled d-flex align-items-center gap-3 mb-0 mt-2">
|
||||
<li
|
||||
v-for="(user, index) in sortedUserList"
|
||||
:key="index"
|
||||
:class="{ disabled: user.disabled }"
|
||||
@click="$emit('profileClick', user)"
|
||||
data-bs-placement="top"
|
||||
:aria-label="user.MEMBERSEQ"
|
||||
>
|
||||
<img
|
||||
class="rounded-circle user-avatar "
|
||||
:src="getUserProfileImage(user.MEMBERPRF)"
|
||||
alt="user"
|
||||
:style="getDynamicStyle(user)"
|
||||
@error="setDefaultImage"
|
||||
@load="showImage"
|
||||
/>
|
||||
<span class="remaining-vacation">
|
||||
{{ remainingVacationData[user.MEMBERSEQ] || 0 }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed, nextTick } from "vue";
|
||||
import { useUserInfoStore } from "@/stores/useUserInfoStore"; // 사용자 정보 스토어 사용
|
||||
import { useUserStore as useUserListStore } from "@s/userList"; // 사원 리스트 스토어
|
||||
import { useUserStore } from "@s/useUserStore";
|
||||
import { useUserStore as useUserListStore } from "@s/userList";
|
||||
import $api from "@api";
|
||||
|
||||
defineEmits(["profileClick"]);
|
||||
|
||||
defineProps({
|
||||
remainingVacationData: Object,
|
||||
});
|
||||
|
||||
const userStore = useUserInfoStore();
|
||||
const userListStore = useUserListStore();
|
||||
|
||||
const userList = ref([]);
|
||||
const userListContainer = ref(null);
|
||||
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, "");
|
||||
const defaultProfile = "/img/icons/icon.png";
|
||||
|
||||
const employeeId = ref(null); // 현재 로그인한 사용자 ID
|
||||
const employeeId = ref(null);
|
||||
const userColors = ref({});
|
||||
|
||||
onMounted(async () => {
|
||||
await userStore.userInfo(); // 로그인한 사용자 정보 가져오기
|
||||
await userListStore.fetchUserList(); // 사원 리스트 가져오기
|
||||
await userStore.userInfo();
|
||||
if (userStore.user) {
|
||||
employeeId.value = userStore.user.id;
|
||||
} else {
|
||||
console.error("❌ 로그인한 사용자 정보를 불러오지 못했습니다.");
|
||||
}
|
||||
|
||||
userList.value = userListStore.userList;
|
||||
await userListStore.fetchUserList();
|
||||
userList.value = userListStore.userList;
|
||||
|
||||
// 로그인한 사용자의 ID 가져오기
|
||||
if (userStore.user) {
|
||||
employeeId.value = userStore.user.id;
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
tooltips.forEach((tooltip) => {
|
||||
new bootstrap.Tooltip(tooltip);
|
||||
});
|
||||
});
|
||||
// 사용자별 색상 저장
|
||||
userList.value.forEach(user => {
|
||||
userColors.value[user.MEMBERSEQ] = user.usercolor || "#ccc";
|
||||
});
|
||||
|
||||
// 내 프로필을 가장 앞에 배치한 리스트 정렬
|
||||
nextTick(() => {
|
||||
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
tooltips.forEach(tooltip => {
|
||||
new bootstrap.Tooltip(tooltip);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const sortedUserList = computed(() => {
|
||||
if (!employeeId.value) return userList.value; // 로그인한 사용자가 없으면 기존 리스트 반환
|
||||
|
||||
// 내 프로필 찾기
|
||||
const myProfile = userList.value.find(user => user.MEMBERSEQ === employeeId.value);
|
||||
const otherUsers = userList.value.filter(user => user.MEMBERSEQ !== employeeId.value);
|
||||
|
||||
@ -87,7 +97,7 @@
|
||||
const totalUsers = userList.value.length;
|
||||
|
||||
if (totalUsers <= 7) return "120px"; // 7명 이하
|
||||
if (totalUsers <= 10) return "110px"; // ~10명
|
||||
if (totalUsers <= 10) return "100px"; // ~10명
|
||||
if (totalUsers <= 20) return "80px"; // ~20명
|
||||
return "60px"; // 20명 이상
|
||||
});
|
||||
@ -97,11 +107,20 @@
|
||||
return {
|
||||
width: profileSize.value,
|
||||
height: profileSize.value,
|
||||
borderWidth: "3px",
|
||||
borderWidth: "4px",
|
||||
borderColor: user.usercolor || "#ccc",
|
||||
borderStyle: "solid",
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 남은 연차 개수 스타일 */
|
||||
.remaining-vacation {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
margin-top: 5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,69 +1,146 @@
|
||||
<template>
|
||||
<li class="mt-5 card p-5">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="w-100 d-flex align-items-center">
|
||||
<span class="btn btn-primary pe-none">{{ item.category }}</span>
|
||||
<strong class="mx-2 w-75">{{ item.WRDDICTTL }}</strong>
|
||||
</div>
|
||||
<EditBtn />
|
||||
</div>
|
||||
<p class="mt-5" v-html="$common.contentToHtml(item.WRDDICCON)"></p>
|
||||
<div class="d-flex justify-content-between flex-wrap gap-2 mb-2">
|
||||
<div class="d-flex flex-wrap align-items-center mb-50">
|
||||
<div class="avatar avatar-sm me-2">
|
||||
<img
|
||||
class="rounded-circle user-avatar"
|
||||
:src="getProfileImage(item.author.profileImage)"
|
||||
alt="최초 작성자"
|
||||
:style="{ borderColor: item.author.color}"
|
||||
/>
|
||||
<DictWrite
|
||||
v-if="isWriteVisible"
|
||||
@close="isWriteVisible = false"
|
||||
:dataList="cateList"
|
||||
@addCategory="addCategory"
|
||||
:formValue="item.WRDDICCAT"
|
||||
:titleValue="item.WRDDICTTL"
|
||||
:contentValue="$common.contentToHtml(item.WRDDICCON)"
|
||||
/>
|
||||
|
||||
<div v-else>
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="w-100 d-flex align-items-center">
|
||||
<span class="btn btn-primary pe-none">{{ item.category }}</span>
|
||||
<strong class="mx-2 w-75">{{ item.WRDDICTTL }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 small fw-medium">{{ formatDate(item.author.createdAt) }}</p>
|
||||
</div>
|
||||
<p class="mt-5" v-html="$common.contentToHtml(item.WRDDICCON)"></p>
|
||||
<div class="d-flex justify-content-between flex-wrap gap-2 mb-2">
|
||||
<div class="d-flex flex-wrap align-items-center mb-50">
|
||||
<div class="avatar avatar-sm me-2">
|
||||
<img
|
||||
class="rounded-circle user-avatar"
|
||||
:src="getProfileImage(item.author.profileImage)"
|
||||
alt="최초 작성자"
|
||||
:style="{ borderColor: item.author.color}"/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 small fw-medium">{{ formatDate(item.author.createdAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between flex-wrap gap-2 mb-2">
|
||||
<div class="d-flex flex-wrap align-items-center mb-50">
|
||||
<div class="avatar avatar-sm me-2">
|
||||
<img
|
||||
class="rounded-circle user-avatar"
|
||||
:src="getProfileImage(item.lastEditor.profileImage)"
|
||||
alt="최근 작성자"
|
||||
:style="{ borderColor: item.lastEditor.color}"/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 small fw-medium">{{ formatDate(item.lastEditor.updatedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between flex-wrap gap-2 mb-2">
|
||||
<div class="d-flex flex-wrap align-items-center mb-50">
|
||||
<div class="avatar avatar-sm me-2">
|
||||
<img
|
||||
class="rounded-circle user-avatar"
|
||||
:src="getProfileImage(item.lastEditor.profileImage)"
|
||||
alt="최근 작성자"
|
||||
:style="{ borderColor: item.lastEditor.color}"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-0 small fw-medium">{{ formatDate(item.lastEditor.updatedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="edit-btn">
|
||||
<EditBtn @click="toggleWriteVisible" />
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import axios from "@api";
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
import { ref, toRefs } from 'vue';
|
||||
import EditBtn from '@/components/button/EditBtn.vue';
|
||||
import $api from '@api';
|
||||
import DictWrite from './DictWrite.vue';
|
||||
|
||||
const toastStore = useToastStore();
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
required: true
|
||||
},
|
||||
cateList: {
|
||||
type: Array,
|
||||
required: false,
|
||||
}
|
||||
});
|
||||
|
||||
// 카테고리
|
||||
const localCateList = ref([...props.cateList]);
|
||||
// 선택 카테고리
|
||||
const selectedCategory = ref('');
|
||||
|
||||
// cateList emit
|
||||
const emit = defineEmits(['update:cateList']);
|
||||
|
||||
// 글 수정 상태
|
||||
const isWriteVisible = ref(false);
|
||||
|
||||
|
||||
// 글 수정 toggle
|
||||
const toggleWriteVisible = () => {
|
||||
isWriteVisible.value = !isWriteVisible.value;
|
||||
};
|
||||
|
||||
//카테고리 등록 수정
|
||||
const addCategory = (data) => {
|
||||
try {
|
||||
const lastCategory = localCateList.value.length > 0
|
||||
? localCateList.value[localCateList.value.length - 1]
|
||||
: null;
|
||||
const newValue = lastCategory ? lastCategory.value + 1 : 600101;
|
||||
|
||||
axios.post('worddict/insertCategory', {
|
||||
CMNCODNAM: data
|
||||
}).then(res => {
|
||||
if(res.data.data === 1){
|
||||
toastStore.onToast('카테고리가 추가 등록 되었습니다.', 's');
|
||||
const newCategory = { label: data, value: newValue };
|
||||
localCateList.value = [newCategory, ...localCateList.value];
|
||||
selectedCategory.value = newCategory.value;
|
||||
|
||||
// 부모에게 전달
|
||||
emit('update:cateList', localCateList.value);
|
||||
}
|
||||
}).catch(err => {
|
||||
console.error('카테고리 추가 중 오류:', err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('카테고리 추가 함수 오류:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
|
||||
// 날짜
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateString) => new Date(dateString).toLocaleString();
|
||||
// 이미지
|
||||
|
||||
// 프로필 이미지
|
||||
const getProfileImage = (imagePath) =>
|
||||
imagePath ? `${baseUrl}upload/img/profile/${imagePath}` : '/img/avatars/default-Profile.jpg';
|
||||
imagePath ? `${baseUrl}upload/img/profile/${imagePath}` : '/img/avatars/default-Profile.jpg';
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 동그란 테두리 설정 */
|
||||
.user-avatar {
|
||||
border: 3px solid; /* 테두리 */
|
||||
padding: 0.1px; /* 테두리와 이미지 사이의 간격 */
|
||||
border: 3px solid; /* 테두리 */
|
||||
padding: 0.1px; /* 테두리와 이미지 사이의 간격 */
|
||||
}
|
||||
</style>
|
||||
|
||||
.edit-btn {
|
||||
position: absolute;
|
||||
right: 0.7rem;
|
||||
top: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
@ -5,10 +5,11 @@
|
||||
<FormSelect
|
||||
name="cate"
|
||||
title="카테고리 선택"
|
||||
:data="formattedDataList"
|
||||
:data="dataList"
|
||||
:is-common="true"
|
||||
@update:data="selectCategory = $event"
|
||||
@change="onChange"
|
||||
:value="formValue"
|
||||
/>
|
||||
</div>
|
||||
<div class="col-2 btn-margin">
|
||||
@ -34,11 +35,13 @@
|
||||
name="word"
|
||||
:is-essential="true"
|
||||
:is-alert="wordTitleAlert"
|
||||
:modelValue="titleValue"
|
||||
@update:modelValue="wordTitle = $event"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<QEditor @update:data="content = $event" @update:imageUrls="imageUrls = $event" :is-alert="wordContentAlert" />
|
||||
{{ contentValue }}
|
||||
<div class="text-end mt-5">
|
||||
<button class="btn btn-primary" @click="saveWord">
|
||||
<i class="bx bx-check"></i>
|
||||
@ -75,17 +78,17 @@ const props = defineProps({
|
||||
dataList: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
formValue : {
|
||||
type:[String, Number]
|
||||
},
|
||||
titleValue : {
|
||||
type:String,
|
||||
},contentValue : {
|
||||
type:String
|
||||
}
|
||||
});
|
||||
|
||||
// 데이터 포맷 수정
|
||||
const formattedDataList = computed(() =>
|
||||
props.dataList.map(item => ({
|
||||
label: item.CMNCODNAM,
|
||||
value: item.CMNCODVAL
|
||||
}))
|
||||
);
|
||||
|
||||
// 카테고리 입력 창
|
||||
const showInput = ref(false);
|
||||
|
||||
|
||||
@ -55,14 +55,14 @@
|
||||
<template v-if="pagination.currentPage === 1 && !showNotices">
|
||||
<tr v-for="(notice, index) in noticeList"
|
||||
:key="'notice-' + index"
|
||||
class="notice-row clickable-row"
|
||||
class="bg-label-gray"
|
||||
@click="goDetail(notice.id)">
|
||||
<td>공지</td>
|
||||
<td>
|
||||
📌 {{ notice.title }}
|
||||
<i v-if="notice.img" class="bi bi-image me-1"></i>
|
||||
<i v-if="notice.hasAttachment" class="bi bi-paperclip"></i>
|
||||
<span v-if="isNewPost(notice.date)" class="badge bg-danger text-white ms-2 new-badge">N</span>
|
||||
<span v-if="isNewPost(notice.date)" class="badge bg-danger text-white ms-2 fs-tiny">N</span>
|
||||
</td>
|
||||
<td>{{ notice.author }}</td>
|
||||
<td>{{ notice.date }}</td>
|
||||
@ -72,14 +72,14 @@
|
||||
<!-- 일반 게시물 -->
|
||||
<tr v-for="(post, index) in generalList"
|
||||
:key="'post-' + index"
|
||||
class="general-row clickable-row"
|
||||
class="invert-bg-white"
|
||||
@click="goDetail(post.realId)">
|
||||
<td>{{ post.id }}</td>
|
||||
<td>
|
||||
{{ post.title }}
|
||||
<i v-if="post.img" class="bi bi-image me-1"></i>
|
||||
<i v-if="post.hasAttachment" class="bi bi-paperclip"></i>
|
||||
<span v-if="isNewPost(post.date)" class="badge bg-danger text-white ms-2 new-badge">N</span>
|
||||
<span v-if="isNewPost(post.date)" class="badge bg-danger text-white ms-2 fs-tiny">N</span>
|
||||
</td>
|
||||
<td>{{ post.author }}</td>
|
||||
<td>{{ post.date }}</td>
|
||||
|
||||
@ -73,17 +73,8 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-4 d-flex justify-content-end">
|
||||
<button type="button" class="btn btn-info" @click="goList">
|
||||
<i class="bx bx-left-arrow-alt"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary ms-1"
|
||||
@click="write"
|
||||
:disabled="!isFileValid"
|
||||
>
|
||||
<i class="bx bx-check"></i>
|
||||
</button>
|
||||
<BackButton @click="goList" />
|
||||
<SaveButton @click="write" :isEnabled="isFileValid" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -98,6 +89,8 @@ import FormFile from '@c/input/FormFile.vue';
|
||||
import { getCurrentInstance, ref, onMounted } from 'vue';
|
||||
import router from '@/router';
|
||||
import axios from '@api';
|
||||
import SaveButton from '@c/button/SaveBtn.vue';
|
||||
import BackButton from '@c/button/BackBtn.vue'
|
||||
|
||||
const categoryList = ref([]);
|
||||
const title = ref('');
|
||||
|
||||
@ -5,8 +5,28 @@
|
||||
<div class="row g-0">
|
||||
<div class="col app-calendar-content">
|
||||
<div class="card shadow-none border-0">
|
||||
<ProfileList />
|
||||
<ProfileList
|
||||
@profileClick="handleProfileClick"
|
||||
:remainingVacationData="remainingVacationData"
|
||||
/>
|
||||
<div class="card-body">
|
||||
<VacationModal
|
||||
v-if="isModalOpen"
|
||||
:isOpen="isModalOpen"
|
||||
:myVacations="myVacations"
|
||||
:receivedVacations="receivedVacations"
|
||||
:userColors="userColors"
|
||||
@click="handleProfileClick(user)"
|
||||
@close="isModalOpen = false"
|
||||
/>
|
||||
<VacationGrantModal
|
||||
v-if="isGrantModalOpen"
|
||||
:isOpen="isGrantModalOpen"
|
||||
:targetUser="selectedUser"
|
||||
:remainingQuota="remainingVacationData[selectedUser?.MEMBERSEQ] || 0"
|
||||
@close="isGrantModalOpen = false"
|
||||
@updateVacation="fetchRemainingVacation"
|
||||
/>
|
||||
<full-calendar
|
||||
ref="fullCalendarRef"
|
||||
:options="calendarOptions"
|
||||
@ -31,22 +51,78 @@ import dayGridPlugin from "@fullcalendar/daygrid";
|
||||
import interactionPlugin from "@fullcalendar/interaction";
|
||||
import "flatpickr/dist/flatpickr.min.css";
|
||||
import "@/assets/css/app-calendar.css";
|
||||
import { reactive, ref, onMounted, nextTick, watchEffect } from "vue";
|
||||
import { reactive, ref, onMounted, nextTick } from "vue";
|
||||
import axios from "@api";
|
||||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
import HalfDayButtons from "@c/button/HalfDayButtons.vue";
|
||||
import ProfileList from "@/components/vacation/ProfileList.vue";
|
||||
import { useUserStore } from "@s/userList";
|
||||
import ProfileList from "@c/vacation/ProfileList.vue";
|
||||
import { useUserStore as useUserListStore } from "@s/userList";
|
||||
import VacationModal from "@c/modal/VacationModal.vue"
|
||||
import { useUserStore } from "@s/useUserStore";
|
||||
import VacationGrantModal from "@c/modal/VacationGrantModal.vue";
|
||||
|
||||
const userStore = useUserStore();
|
||||
const userListStore = useUserListStore();
|
||||
const userList = ref([]);
|
||||
const userColors = ref({});
|
||||
const myVacations = ref([]); // 내가 사용한 연차 목록
|
||||
const receivedVacations = ref([]); // 내가 받은 연차 목록
|
||||
const isModalOpen = ref(false);
|
||||
const remainingVacationData = ref({});
|
||||
|
||||
const isGrantModalOpen = ref(false);
|
||||
const selectedUser = ref(null);
|
||||
|
||||
|
||||
onMounted(async () => {
|
||||
await userStore.userInfo();
|
||||
await fetchRemainingVacation();
|
||||
});
|
||||
|
||||
const fetchRemainingVacation = async () => {
|
||||
try {
|
||||
const response = await axios.get("vacation/remaining");
|
||||
if (response.status === 200) {
|
||||
remainingVacationData.value = response.data.data.reduce((acc, vacation) => {
|
||||
acc[vacation.employeeId] = vacation.remainingQuota;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("🚨 남은 연차 데이터를 불러오지 못했습니다:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 프로필 클릭 시 연차 내역 가져오기
|
||||
const handleProfileClick = async (user) => {
|
||||
try {
|
||||
if (user.MEMBERSEQ === userStore.user.id) {
|
||||
// 내 프로필을 클릭한 경우
|
||||
const response = await axios.get(`vacation/history`);
|
||||
|
||||
if (response.status === 200 && response.data) {
|
||||
myVacations.value = response.data.data.usedVacations || [];
|
||||
receivedVacations.value = response.data.data.receivedVacations || [];
|
||||
isModalOpen.value = true; // 내 연차 모달 열기
|
||||
isGrantModalOpen.value = false;
|
||||
} else {
|
||||
console.warn("❌ 연차 내역을 불러오지 못했습니다.");
|
||||
}
|
||||
} else {
|
||||
// 다른 사람의 프로필을 클릭한 경우
|
||||
selectedUser.value = user;
|
||||
isGrantModalOpen.value = true; // 연차 부여 모달 열기
|
||||
isModalOpen.value = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("🚨 연차 데이터 불러오기 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUserList = async () => {
|
||||
try {
|
||||
await userStore.fetchUserList();
|
||||
userList.value = userStore.userList;
|
||||
await userListStore.fetchUserList();
|
||||
userList.value = userListStore.userList;
|
||||
|
||||
if (!userList.value.length) {
|
||||
console.warn("📌 사용자 목록이 비어 있음!");
|
||||
@ -68,6 +144,7 @@ const calendarEvents = ref([]); // 최종적으로 FullCalendar에 표시할 이
|
||||
const fetchedEvents = ref([]); // API에서 불러온 이벤트 (휴가, 공휴일)
|
||||
const selectedDates = ref(new Map()); // 사용자가 클릭한 날짜 및 타입
|
||||
const halfDayType = ref(null);
|
||||
const vacationCodeMap = ref({}); // 휴가 코드명 저장용
|
||||
|
||||
// 공휴일 날짜(YYYY-MM-DD 형식)를 저장 (클릭 불가 처리용)
|
||||
const holidayDates = ref(new Set());
|
||||
@ -88,6 +165,28 @@ datesSet: handleMonthChange,
|
||||
events: calendarEvents,
|
||||
});
|
||||
|
||||
const fetchVacationCodes = async () => {
|
||||
try {
|
||||
const response = await axios.get("vacation/codes");
|
||||
if (response.status === 200 && response.data) {
|
||||
// 배열을 객체 형태로 변환
|
||||
vacationCodeMap.value = response.data.data.reduce((acc, item) => {
|
||||
acc[item.code] = item.name; // code를 key로, name을 value로 설정
|
||||
return acc;
|
||||
}, {});
|
||||
} else {
|
||||
console.warn("❌ 공통 코드 데이터를 불러오지 못했습니다.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("🚨 공통 코드 API 호출 실패:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// 🔹 typeCode를 통해 code명 반환
|
||||
const getVacationType = (typeCode) => {
|
||||
return vacationCodeMap.value[typeCode] || "기타";
|
||||
};
|
||||
|
||||
/**
|
||||
* API 이벤트(fetchedEvents)와 사용자가 선택한 날짜(selectedDates)를 병합하여
|
||||
* calendarEvents를 업데이트하는 함수
|
||||
@ -96,29 +195,26 @@ events: calendarEvents,
|
||||
*/
|
||||
function updateCalendarEvents() {
|
||||
const selectedEvents = Array.from(selectedDates.value).map(([date, type]) => {
|
||||
let className = "";
|
||||
let title = "";
|
||||
if (type === "D") {
|
||||
className = "selected-am"; // 오전: 왼쪽 절반
|
||||
title = "오전반차 (선택)";
|
||||
} else if (type === "N") {
|
||||
className = "selected-pm"; // 오후: 오른쪽 절반
|
||||
title = "오후반차 (선택)";
|
||||
} else {
|
||||
className = "selected-full"; // 전체 영역
|
||||
title = "연차 (선택)";
|
||||
}
|
||||
return {
|
||||
title,
|
||||
title: getVacationType(type),
|
||||
start: date,
|
||||
backgroundColor: "rgba(0, 128, 0, 0.3)",
|
||||
display: "background",
|
||||
classNames: [className],
|
||||
classNames: [getVacationTypeClass(type)],
|
||||
};
|
||||
});
|
||||
calendarEvents.value = [...fetchedEvents.value, ...selectedEvents];
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ 반차 유형에 따라 클래스명 지정 (색상 변경 없이 영역만 조정)
|
||||
*/
|
||||
const getVacationTypeClass = (type) => {
|
||||
if (type === "700101") return "half-day-am"; // 오전반차 → 왼쪽 절반
|
||||
if (type === "700102") return "half-day-pm"; // 오후반차 → 오른쪽 절반
|
||||
return "full-day"; // 연차 → 전체 배경
|
||||
};
|
||||
|
||||
/**
|
||||
* 날짜 클릭 이벤트
|
||||
* - 주말(토, 일)과 공휴일은 클릭되지 않음
|
||||
@ -139,9 +235,9 @@ if (holidayDates.value.has(clickedDateStr)) {
|
||||
if (!selectedDates.value.has(clickedDateStr)) {
|
||||
const type = halfDayType.value
|
||||
? halfDayType.value === "AM"
|
||||
? "D"
|
||||
: "N"
|
||||
: "F";
|
||||
? "700101"
|
||||
: "700102"
|
||||
: "700103";
|
||||
selectedDates.value.set(clickedDateStr, type);
|
||||
} else {
|
||||
selectedDates.value.delete(clickedDateStr);
|
||||
@ -170,22 +266,11 @@ try {
|
||||
let dateStr = vac.LOCVACUDT.split("T")[0];
|
||||
let className = "fc-daygrid-event";
|
||||
let backgroundColor = userColors.value[vac.MEMBERSEQ] || "#FFFFFF";
|
||||
let title = "연차";
|
||||
if (vac.LOCVACTYP === "D") {
|
||||
title = "오전반차";
|
||||
className += " half-day-am";
|
||||
} else if (vac.LOCVACTYP === "N") {
|
||||
title = "오후반차";
|
||||
className += " half-day-pm";
|
||||
} else if (vac.LOCVACTYP === "F") {
|
||||
title = "연차";
|
||||
className += " full-day";
|
||||
}
|
||||
return {
|
||||
title,
|
||||
title: getVacationType(vac.LOCVACTYP),
|
||||
start: dateStr,
|
||||
backgroundColor,
|
||||
classNames: [className],
|
||||
classNames: [getVacationTypeClass(vac.LOCVACTYP)],
|
||||
};
|
||||
})
|
||||
.filter((event) => event !== null);
|
||||
@ -216,6 +301,7 @@ try {
|
||||
const response = await axios.post("vacation", vacationRequests);
|
||||
if (response.data && response.data.status === "OK") {
|
||||
alert("휴가가 저장되었습니다.");
|
||||
await fetchRemainingVacation();
|
||||
// 저장 후 현재 달 데이터 다시 불러오기
|
||||
const currentDate = fullCalendarRef.value.getApi().getDate();
|
||||
const year = currentDate.getFullYear();
|
||||
@ -281,6 +367,7 @@ fullCalendarRef.value.getApi().refetchEvents();
|
||||
// 컴포넌트 마운트 시 현재 달의 데이터 로드
|
||||
onMounted(async () => {
|
||||
await fetchUserList(); // 사용자 목록 먼저 불러오기
|
||||
await fetchVacationCodes();
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, "0");
|
||||
|
||||
@ -45,9 +45,10 @@
|
||||
<!-- 단어 목록 -->
|
||||
<ul v-if="total > 0" class="px-0 list-unstyled">
|
||||
<DictCard
|
||||
v-for="item in wordList"
|
||||
:key="item.WRDDICSEQ"
|
||||
:item="item"
|
||||
v-for="item in wordList"
|
||||
:key="item.WRDDICSEQ"
|
||||
:item="item"
|
||||
:cateList="cateList"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
@ -61,7 +62,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watchEffect, computed, onMounted, getCurrentInstance } from 'vue';
|
||||
import { ref, watchEffect, computed, onMounted, getCurrentInstance, toRaw } from 'vue';
|
||||
import axios from '@api';
|
||||
import SearchBar from '@c/search/SearchBar.vue';
|
||||
import WriteButton from '@c/button/WriteBtn.vue';
|
||||
@ -69,7 +70,7 @@
|
||||
import DictCard from '@/components/wordDict/DictCard.vue';
|
||||
import DictWrite from '@/components/wordDict/DictWrite.vue';
|
||||
import DictAlphabetFilter from '@/components/wordDict/DictAlphabetFilter.vue';
|
||||
import commonApi from '@/common/commonApi'
|
||||
import commonApi from '@/common/commonApi';
|
||||
import { useToastStore } from '@s/toastStore';
|
||||
|
||||
const { appContext } = getCurrentInstance();
|
||||
@ -88,8 +89,13 @@
|
||||
const total = ref(0);
|
||||
|
||||
// 카테고리
|
||||
const { cateList } = commonApi();
|
||||
const { cateList } = commonApi({
|
||||
loadCateList: true
|
||||
});
|
||||
|
||||
const selectedCategory = ref('');
|
||||
const selectCategory = ref('');
|
||||
|
||||
|
||||
//선택된 알파벳
|
||||
const selectedAlphabet = ref('');
|
||||
@ -155,16 +161,16 @@
|
||||
//카테고리 등록
|
||||
const addCategory = (data) =>{
|
||||
const lastCategory = cateList.value[cateList.value.length - 1];
|
||||
const newValue = lastCategory ? parseInt(lastCategory.CMNCODVAL) + 1 : 600101;
|
||||
const newValue = lastCategory ? lastCategory.value + 1 : 600101;
|
||||
|
||||
axios.post('worddict/insertCategory',{
|
||||
CMNCODNAM: data
|
||||
}).then(res => {
|
||||
if(res.data.data == '1'){
|
||||
toastStore.onToast('카테고리가 추가 등록 되었습니다.', 's');
|
||||
const newCategory = { CMNCODNAM: data, CMNCODVAL: newValue.toString() };
|
||||
cateList.value.unshift(newCategory);
|
||||
selectCategory.value = newCategory.CMNCODVAL;
|
||||
const newCategory = { label: data, value: newValue };
|
||||
cateList.value = [newCategory, ...cateList.value];
|
||||
selectedCategory.value = newCategory.value;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,13 +1,20 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import vueDevTools from 'vite-plugin-vue-devtools';
|
||||
import mkcert from 'vite-plugin-mkcert';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
// 자신의 로컬 서버에 연결하려면 이부분 주석처리
|
||||
mkcert({
|
||||
// SSL 키 등록
|
||||
keyFile: '/localhost-key.pem',
|
||||
certFile: '/localhost.pem',
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
@ -18,7 +25,7 @@ export default defineConfig({
|
||||
'@l': fileURLToPath(new URL('./src/layout/', import.meta.url)),
|
||||
'@s': fileURLToPath(new URL('./src/stores/', import.meta.url)),
|
||||
'@p': fileURLToPath(new URL('./src/common/plugin/', import.meta.url)),
|
||||
"@api": fileURLToPath(new URL('./src/common/axios-interceptor.js', import.meta.url))
|
||||
'@api': fileURLToPath(new URL('./src/common/axios-interceptor.js', import.meta.url)),
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user