Merge branch 'main' into commuters

This commit is contained in:
yoon 2025-03-11 09:24:00 +09:00
commit 65e620d579
36 changed files with 1446 additions and 1146 deletions

View File

@ -3,6 +3,9 @@
/* 휴가 */ /* 휴가 */
.fc-daygrid-event {
pointer-events: none !important;
}
/* 이벤트 선 없게 */ /* 이벤트 선 없게 */
.fc-event { .fc-event {
border: none; border: none;
@ -103,13 +106,14 @@ cursor: not-allowed !important;
padding-bottom: 20px; padding-bottom: 20px;
} }
.vac-modal-content { .vac-modal-content {
background: #fff; background: #fff;
padding: 20px; padding: 20px;
border-radius: 8px; border-radius: 8px;
box-shadow: 0px -4px 10px rgba(0, 0, 0, 0.1); /* 위쪽 그림자만 적용 */ box-shadow: 0px -4px 5px rgba(0, 0, 0, 0.1),
max-width: 500px; 0px 4px 0px rgba(0, 0, 0, 0);
width: 100%; max-width: 500px;
position: relative; width: 100%;
position: relative;
} }
.vac-modal-body { .vac-modal-body {
max-height: 140px; max-height: 140px;
@ -208,20 +212,25 @@ cursor: not-allowed !important;
.profile-list { .profile-list {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: flex-start; gap: 15px;
gap: 10px;
padding: 0; padding: 0;
margin-left: 20px; list-style: none;
justify-content: flex-start;
cursor: pointer;
} }
.profile-img { .profile-item {
transition: all 0.2s ease-in-out; display: flex;
flex-direction: column;
align-items: center;
cursor: pointer;
width: calc(33.33% - 10px);
} }
/* 오전/오후반차,저장버튼 */ /* 오전/오후반차,저장버튼 */
/* 버튼 기본 스타일 */ /* 버튼 기본 스타일 */
.vac-btn { .vac-btn {
width: 50px; width: 60px;
height: 50px; height: 60px;
border-radius: 50%; border-radius: 50%;
font-size: 20px; font-size: 20px;
display: flex; display: flex;
@ -250,20 +259,19 @@ cursor: not-allowed !important;
} }
/* 선택된 (눌린) 버튼 */ /* 선택된 (눌린) 버튼 */
.vac-btn.active { .vac-btn.active {
border: 3px solid #ff0000; /* 붉은색 테두리 적용 */ box-shadow: 0px 4px 15px rgba(224, 224, 224, 0.3);
box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.3);
transform: scale(1.1); transform: scale(1.1);
} }
.vac-btn-warning{ .vac-btn-warning{
color: #fff; color: #fff;
background-color: #ffab00; background-color: #ffc144;
border-color: #ffab00; border-color: #ffe605;
box-shadow: 0 0.125rem 0.25rem 0 rgba(255, 171, 0, 0.4); box-shadow: 0 0.125rem 0.25rem 0 rgba(255, 171, 0, 0.4);
} }
/* AM 버튼 (선택된 상태) */ /* AM 버튼 (선택된 상태) */
.vac-btn-warning.active { .vac-btn-warning.active {
background-color: #ffca2c !important; background-color: #ff7300 !important;
color: black; color: #fff;;
} }
.vac-btn-info { .vac-btn-info {
color: #fff; color: #fff;
@ -277,10 +285,10 @@ cursor: not-allowed !important;
color: white; color: white;
} }
/* 버튼 기본 (비활성화일 때 기본 녹색) */ /* 버튼 기본 (비활성화일 때 기본 녹색) */
.btn-success { .vac-btn-success {
font-size: 24px; font-size: 24px;
width: 50px; width: 60px;
height: 50px; height: 60px;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@ -292,7 +300,7 @@ cursor: not-allowed !important;
border: 1px solid transparent; border: 1px solid transparent;
} }
/* 버튼 활성화 */ /* 버튼 활성화 */
.btn-success.active { .vac-btn-success.active {
background-color: #ff0000 !important; background-color: #ff0000 !important;
color: white !important; color: white !important;
border: 3px solid #eb9f9f !important; border: 3px solid #eb9f9f !important;
@ -300,7 +308,8 @@ cursor: not-allowed !important;
transform: scale(1.1); transform: scale(1.1);
} }
/* 버튼 비활성화 */ /* 버튼 비활성화 */
.btn-success.disabled { .vac-btn-success.disabled {
border: 3px solid #e6e4e4; /* 붉은색 테두리 적용 */
background-color: #bbb8b8 !important; background-color: #bbb8b8 !important;
color: white !important; color: white !important;
cursor: not-allowed !important; cursor: not-allowed !important;
@ -310,7 +319,7 @@ cursor: not-allowed !important;
} }
/* 작은 화면에서 버튼 크기 조정 */ /* 작은 화면에서 버튼 크기 조정 */
@media (max-width: 1600px) { @media (max-width: 1700px) {
.count-btn { .count-btn {
width: 28px; width: 28px;
height: 28px; height: 28px;
@ -346,12 +355,34 @@ cursor: not-allowed !important;
text-align: center; text-align: center;
margin-bottom: 5px; margin-bottom: 5px;
} }
.vac-btn {
width: 50px;
height: 50px;
font-size: 18px;
}
.vac-btn-success {
font-size: 20px;
width: 50px;
height: 50px;
}
}
@media (max-width: 1500px) {
.close-btn {
top: 5px;
right: 5px;
font-size: 13px;
}
.vacation-item {
font-size: 11px;
text-align: center;
margin-bottom: 5px;
}
.vac-btn { .vac-btn {
width: 40px; width: 40px;
height: 40px; height: 40px;
font-size: 18px; font-size: 18px;
} }
.btn-success { .vac-btn-success {
font-size: 20px; font-size: 20px;
width: 40px; width: 40px;
height: 40px; height: 40px;
@ -359,7 +390,6 @@ cursor: not-allowed !important;
} }
.grayscaleImg { .grayscaleImg {
filter: grayscale(100%); filter: grayscale(100%);
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -53,7 +53,7 @@ $api.interceptors.response.use(
switch (error.response.status) { switch (error.response.status) {
case 401: case 401:
if (!error.config.headers.isLogin) { if (!error.config.headers.isLogin) {
toastStore.onToast('인증이 필요합니다.', 'e'); // toastStore.onToast('인증이 필요합니다.', 'e');
} }
break; break;
case 403: case 403:

View File

@ -81,6 +81,34 @@ const common = {
return true; 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;
},
}; };
export default { export default {

View File

@ -16,7 +16,7 @@
@updateReaction="handleUpdateReaction" @updateReaction="handleUpdateReaction"
/> />
<!-- 댓글 비밀번호 입력창 (익명일 경우) --> <!-- 댓글 비밀번호 입력창 (익명일 경우) -->
<div v-if="currentPasswordCommentId === comment.commentId && unknown" class="mt-3 w-25 ms-auto"> <div v-if="currentPasswordCommentId === comment.commentId && unknown && comment.author == '익명'" class="mt-3 w-25 ms-auto">
<div class="input-group"> <div class="input-group">
<input <input
type="password" type="password"
@ -47,151 +47,129 @@
<!-- <template v-if="isDeleted"> <!-- <template v-if="isDeleted">
<p class="m-0 text-muted">댓글이 삭제되었습니다.</p> <p class="m-0 text-muted">댓글이 삭제되었습니다.</p>
</template> --> </template> -->
<PlusButton v-if="isPlusButton" @click="toggleComment" class="mt-6"/> <PlusButton v-if="isPlusButton" @click="toggleComment" class="mt-6" />
<BoardCommentArea v-if="isComment" :unknown="unknown" @submitComment="submitComment"/> <BoardCommentArea v-if="isComment" :unknown="unknown" @submitComment="submitComment" :commnetId="comment.commentId" />
<!-- 대댓글 --> <slot name="reply"></slot>
<ul v-if="comment.children && comment.children.length" class="list-unstyled">
<li
v-for="child in comment.children"
:key="child.commentId"
class="mt-8 pt-6 ps-10 border-top"
>
<BoardComment
:comment="child"
:unknown="child.author === '익명'"
:isPlusButton="false"
:isLike="true"
:isCommentProfile="true"
:isCommentAuthor="child.isCommentAuthor"
:isCommentPassword="isCommentPassword"
:currentPasswordCommentId="currentPasswordCommentId"
:passwordCommentAlert="passwordCommentAlert"
:password="password"
@editClick="handleReplyEditClick"
@deleteClick="$emit('deleteClick', child)"
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent)"
@cancelEdit="$emit('cancelEdit', child)"
@submitComment="submitComment"
@updateReaction="handleUpdateReaction"
@submitPassword="$emit('submitPassword', child, password)"
@update:password="$emit('update:password', $event)"
/>
</li>
</ul>
</div> </div>
</template> </template>
<script setup> <script setup>
import { defineProps, defineEmits, ref, computed, watch } from 'vue'; import { defineProps, defineEmits, ref, computed, watch } from 'vue';
import BoardProfile from './BoardProfile.vue'; import BoardProfile from './BoardProfile.vue';
import BoardCommentArea from './BoardCommentArea.vue'; import BoardCommentArea from './BoardCommentArea.vue';
import PlusButton from '../button/PlusBtn.vue'; import PlusButton from '../button/PlusBtn.vue';
import SaveBtn from '../button/SaveBtn.vue'; import SaveBtn from '../button/SaveBtn.vue';
const props = defineProps({ const props = defineProps({
comment: { comment: {
type: Object, type: Object,
required: true, required: true,
}, },
unknown: { unknown: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isCommentAuthor: { isCommentAuthor: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isPlusButton: { isPlusButton: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
isLike: { isLike: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isEditTextarea: { isEditTextarea: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
isDeleted: { isDeleted: {
type: Boolean, type: Boolean,
default: false default: false,
}, },
isCommentPassword: { isCommentPassword: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
passwordCommentAlert: { passwordCommentAlert: {
type: String, type: String,
default: '' default: '',
}, },
currentPasswordCommentId: { currentPasswordCommentId: {
type: Number type: Number,
}, },
password:{ password: {
type: String type: String,
}, },
});
// emits
const emit = defineEmits(['submitComment', 'updateReaction', 'editClick', 'deleteClick', 'submitPassword', 'submitEdit', 'cancelEdit', 'update:password']);
const localEditedContent = ref(props.comment.content);
//
const isComment = ref(false);
const toggleComment = () => {
isComment.value = !isComment.value;
};
//
const submitComment = (newComment) => {
emit('submitComment', { parentId: props.comment.commentId, ...newComment, LOCBRDTYP: newComment.LOCBRDTYP });
isComment.value = false;
};
// ,
const handleUpdateReaction = (reactionData) => {
emit('updateReaction', {
boardId: props.comment.boardId,
commentId: props.comment.commentId || reactionData.commentId,
...reactionData,
}); });
}; // emits
const emit = defineEmits([
'submitComment',
'updateReaction',
'editClick',
'deleteClick',
'submitPassword',
'submitEdit',
'cancelEdit',
'update:password',
]);
// const localEditedContent = ref(props.comment.content);
const logPasswordAndEmit = () => {
emit('submitPassword', props.comment, props.password);
};
watch(() => props.comment.isEditTextarea, (newVal) => { //
if (newVal) { const isComment = ref(false);
localEditedContent.value = props.comment.content; const toggleComment = () => {
} isComment.value = !isComment.value;
}); };
// watch(() => props.comment.isDeleted, () => { //
// console.log("BoardComment - isDeleted :", newVal); const submitComment = newComment => {
emit('submitComment', { parentId: props.comment.commentId, ...newComment, LOCBRDTYP: newComment.LOCBRDTYP });
isComment.value = false;
};
// if (newVal) { // ,
// localEditedContent.value = " ."; // UI const handleUpdateReaction = reactionData => {
// props.comment.isEditTextarea = false; emit('updateReaction', {
// } boardId: props.comment.boardId,
// }); commentId: props.comment.commentId || reactionData.commentId,
...reactionData,
});
};
// //
const submitEdit = () => { const logPasswordAndEmit = () => {
emit('submitEdit', props.comment, localEditedContent.value); emit('submitPassword', props.comment, props.password);
}; };
const handleEditClick = () => { watch(
emit('editClick', props.comment); () => props.comment.isEditTextarea,
} newVal => {
if (newVal) {
localEditedContent.value = props.comment.content;
}
},
);
const handleReplyEditClick = (comment) => { // watch(() => props.comment.isDeleted, () => {
emit('editClick', comment); // 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> </script>

View File

@ -11,7 +11,14 @@
</div> --> </div> -->
<!-- 텍스트박스 --> <!-- 텍스트박스 -->
<div class="w-100"> <div class="w-100">
<textarea class="form-control" placeholder="댓글 달기" rows="3" v-model="comment"></textarea> <textarea
class="form-control"
placeholder="댓글 달기"
rows="3"
:maxlength="maxLength"
v-model="comment"
@input="alertTextHandler"
></textarea>
<span v-if="commentAlert" class="invalid-feedback d-block text-start ms-2">{{ commentAlert }}</span> <span v-if="commentAlert" class="invalid-feedback d-block text-start ms-2">{{ commentAlert }}</span>
<span v-else class="invalid-feedback d-block text-start ms-2">{{ textAlert }}</span> <span v-else class="invalid-feedback d-block text-start ms-2">{{ textAlert }}</span>
</div> </div>
@ -22,8 +29,8 @@
<div class="d-flex flex-wrap align-items-center"> <div class="d-flex flex-wrap align-items-center">
<!-- 익명 체크박스 (익명게시판일 경우에만)--> <!-- 익명 체크박스 (익명게시판일 경우에만)-->
<div v-if="unknown" class="form-check form-check-inline mb-0 me-4"> <div v-if="unknown" class="form-check form-check-inline mb-0 me-4">
<input class="form-check-input" type="checkbox" id="inlineCheckbox1" v-model="isCheck" /> <input class="form-check-input" type="checkbox" :id="`checkboxAnnonymous${commnetId}`" v-model="isCheck" />
<label class="form-check-label" for="inlineCheckbox1">익명</label> <label class="form-check-label" :for="`checkboxAnnonymous${commnetId}`">익명</label>
</div> </div>
<!-- 비밀번호 입력 필드 (익명이 선택된 경우에만 표시) --> <!-- 비밀번호 입력 필드 (익명이 선택된 경우에만 표시) -->
@ -35,6 +42,7 @@
class="form-control flex-grow-1" class="form-control flex-grow-1"
v-model="password" v-model="password"
placeholder="비밀번호 입력" placeholder="비밀번호 입력"
@input="passwordAlertTextHandler"
/> />
<span v-if="passwordAlert" class="invalid-feedback d-block text-start ms-2">{{ passwordAlert }}</span> <span v-if="passwordAlert" class="invalid-feedback d-block text-start ms-2">{{ passwordAlert }}</span>
<span v-else class="invalid-feedback d-block text-start ms-2">{{ passwordAlert2 }}</span> <span v-else class="invalid-feedback d-block text-start ms-2">{{ passwordAlert2 }}</span>
@ -51,78 +59,92 @@
</template> </template>
<script setup> <script setup>
import { ref, defineEmits, defineProps, watch, inject } from 'vue'; import { ref, defineEmits, defineProps, watch, inject } from 'vue';
import SaveBtn from '../button/SaveBtn.vue'; import SaveBtn from '../button/SaveBtn.vue';
const props = defineProps({ const props = defineProps({
unknown: { unknown: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
parentId: { parentId: {
type: Number, type: Number,
default: 0, default: 0,
}, },
passwordAlert: { passwordAlert: {
type: String, type: String,
default: '', default: '',
}, },
commentAlert: { commentAlert: {
type: String, type: String,
default: '', default: '',
}, },
}); maxLength: {
type: Number,
const $common = inject('common'); default: 500,
const comment = ref(''); },
const password = ref(''); commnetId: {
const isCheck = ref(false); type: Number,
const textAlert = ref(''); },
const passwordAlert2 = ref('');
const emit = defineEmits(['submitComment']);
const handleCommentSubmit = () => {
if (!$common.isNotEmpty(comment.value)) {
textAlert.value = '댓글을 입력하세요';
return false;
} else {
textAlert.value = '';
}
if (isCheck.value && !$common.isNotEmpty(password.value)) {
passwordAlert2.value = '비밀번호를 입력하세요';
return false;
} else {
passwordAlert2.value = '';
}
//
emit('submitComment', {
comment: comment.value,
password: isCheck.value ? password.value : '',
isCheck: isCheck.value,
LOCBRDTYP: isCheck.value ? '300102' : null, // '300102'
}); });
// const $common = inject('common');
resetCommentForm(); const comment = ref('');
}; const password = ref('');
const isCheck = ref(false);
const textAlert = ref('');
const passwordAlert2 = ref('');
// const emit = defineEmits(['submitComment']);
const resetCommentForm = () => {
comment.value = '';
password.value = '';
isCheck.value = false;
};
watch( const alertTextHandler = () => {
() => props.passwordAlert, textAlert.value = '';
() => { };
if (!props.passwordAlert) {
resetCommentForm(); const passwordAlertTextHandler = () => {
passwordAlert2.value = '';
};
const handleCommentSubmit = () => {
if (!$common.isNotEmpty(comment.value)) {
textAlert.value = '댓글을 입력하세요';
return false;
} else {
textAlert.value = '';
} }
}
);
</script>
if (isCheck.value && !$common.isNotEmpty(password.value)) {
passwordAlert2.value = '비밀번호를 입력하세요';
return false;
} else {
passwordAlert2.value = '';
}
//
emit('submitComment', {
comment: comment.value,
password: isCheck.value ? password.value : '',
isCheck: isCheck.value,
LOCBRDTYP: isCheck.value ? '300102' : null, // '300102'
});
//
resetCommentForm();
};
//
const resetCommentForm = () => {
comment.value = '';
password.value = '';
isCheck.value = false;
};
watch(
() => props.passwordAlert,
() => {
if (!props.passwordAlert) {
resetCommentForm();
}
},
);
</script>

View File

@ -1,10 +1,6 @@
<template> <template>
<ul class="list-unstyled mt-10"> <ul class="list-unstyled mt-10">
<li <li v-for="comment in comments" :key="comment.commentId" class="mt-6 border-bottom pb-6">
v-for="comment in comments"
:key="comment.commentId"
class="mt-6 border-bottom pb-6"
>
<BoardComment <BoardComment
:unknown="unknown" :unknown="unknown"
:comment="comment" :comment="comment"
@ -21,104 +17,148 @@
@submitComment="submitComment" @submitComment="submitComment"
@submitEdit="handleSubmitEdit" @submitEdit="handleSubmitEdit"
@cancelEdit="handleCancelEdit" @cancelEdit="handleCancelEdit"
@updateReaction="(reactionData) => handleUpdateReaction(reactionData, comment.commentId, comment.boardId)" @updateReaction="reactionData => handleUpdateReaction(reactionData, comment.commentId, comment.boardId)"
@update:password="updatePassword" @update:password="updatePassword"
/> >
<!-- 대댓글 -->
<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"
:isCommentAuthor="child.isCommentAuthor"
:isCommentPassword="isCommentPassword"
:currentPasswordCommentId="currentPasswordCommentId"
:passwordCommentAlert="passwordCommentAlert"
:password="password"
@editClick="handleReplyEditClick"
@deleteClick="$emit('deleteClick', child)"
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent)"
@cancelEdit="$emit('cancelEdit', child)"
@submitComment="submitComment"
@updateReaction="handleUpdateReaction"
@submitPassword="$emit('submitPassword', child, password)"
@update:password="$emit('update:password', $event)"
/>
</li>
</ul>
</template>
</BoardComment>
</li> </li>
</ul> </ul>
</template> </template>
<script setup> <script setup>
import { defineProps, defineEmits } from 'vue'; import { defineProps, defineEmits } from 'vue';
import BoardComment from './BoardComment.vue' import BoardComment from './BoardComment.vue';
const props = defineProps({ const props = defineProps({
comments: { comments: {
type: Array, type: Array,
required: true, required: true,
default: () => [] default: () => [],
}, },
unknown: { unknown: {
type: Boolean, type: Boolean,
default: true, default: true,
}, },
isCommentAuthor: { isCommentAuthor: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isCommentPassword: { isCommentPassword: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isEditTextarea: { isEditTextarea: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isDeleted: { isDeleted: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
passwordCommentAlert: { passwordCommentAlert: {
type: String, type: String,
default: '' default: '',
}, },
currentPasswordCommentId: { currentPasswordCommentId: {
type: Number type: Number,
}, },
password:{ password: {
type: String type: String,
}, },
}); index: {
type: Number,
},
});
const emit = defineEmits(['submitComment', 'updateReaction', 'editClick', 'deleteClick', 'submitPassword', 'clearPassword','submitEdit', 'update:password']); const emit = defineEmits([
'submitComment',
'updateReaction',
'editClick',
'deleteClick',
'submitPassword',
'clearPassword',
'submitEdit',
'update:password',
]);
const submitComment = (replyData) => { const submitComment = replyData => {
emit('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 handleUpdateReaction = (reactionData, commentId, boardId) => {
} const updatedReactionData = {
...reactionData,
commentId: commentId || reactionData.commentId,
boardId: boardId || reactionData.boardId,
};
const submitPassword = (comment, password) => { emit('updateReaction', updatedReactionData);
emit('submitPassword', comment, password); };
};
const handleEditClick = (comment) => { const submitPassword = (comment, password) => {
if (comment.parentId) { emit('submitPassword', comment, password);
emit('editClick', comment); // };
} else {
emit('editClick', comment); //
}
};
const handleSubmitEdit = (comment, editedContent) => { const handleEditClick = comment => {
emit("submitEdit", comment, editedContent); if (comment.parentId) {
}; emit('editClick', comment); //
} else {
emit('editClick', comment); //
}
};
const handleDeleteClick = (comment) => { const handleSubmitEdit = (comment, editedContent) => {
if (comment.parentId) { emit('submitEdit', comment, editedContent);
emit('deleteClick', comment); // };
} else {
emit('deleteClick', comment); //
}
};
const handleCancelEdit = (comment) => { const handleDeleteClick = comment => {
if (comment.parentId) { if (comment.parentId) {
emit('cancelEdit', comment); // emit('deleteClick', comment); //
} else { } else {
emit('cancelEdit', comment); // emit('deleteClick', comment); //
} }
}; };
const updatePassword = (newPassword) => { const handleCancelEdit = comment => {
emit('update:password', newPassword); if (comment.parentId) {
}; emit('cancelEdit', comment); //
} else {
emit('cancelEdit', comment); //
}
};
const updatePassword = newPassword => {
emit('update:password', newPassword);
};
const handleReplyEditClick = comment => {
emit('editClick', comment);
};
</script> </script>

View File

@ -40,7 +40,7 @@
const defaultProfile = '/img/icons/icon.png'; const defaultProfile = '/img/icons/icon.png';
// (Vue ) // (Vue )
const baseUrl = 'http://localhost:10325/'; // API URL const baseUrl = import.meta.env.VITE_SERVER; // API URL
// Props // Props
const props = defineProps({ const props = defineProps({

View File

@ -1,124 +1,123 @@
<template v-if="isRecommend"> <template v-if="isRecommend">
<button class="btn btn-label-primary btn-icon" :class="{'clicked': likeClicked, 'big': bigBtn}" @click="handleLike"> <button class="btn btn-label-primary btn-icon" :class="{ clicked: likeClicked, big: bigBtn }" @click="handleLike">
<i class="fa-regular fa-thumbs-up"></i> <span class="num">{{ likeCount }}</span> <i class="fa-regular fa-thumbs-up"></i> <span class="num">{{ likeCount }}</span>
</button> </button>
<button class="btn btn-label-danger btn-icon" :class="{'clicked': dislikeClicked, 'big': bigBtn}" @click="handleDislike"> <button class="btn btn-label-danger btn-icon" :class="{ clicked: dislikeClicked, big: bigBtn }" @click="handleDislike">
<i class="fa-regular fa-thumbs-down"></i> <span class="num">{{ dislikeCount }}</span> <i class="fa-regular fa-thumbs-down"></i> <span class="num">{{ dislikeCount }}</span>
</button> </button>
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
const props = defineProps({ const props = defineProps({
comment: { comment: {
type: Object, type: Object,
default: () => ({}), default: () => ({}),
}, },
likeClicked : { likeClicked: {
type : Boolean, type: Boolean,
default : false, default: false,
}, },
dislikeClicked : { dislikeClicked: {
type : Boolean, type: Boolean,
default : false, default: false,
}, },
bigBtn : { bigBtn: {
type :Boolean, type: Boolean,
default : false, default: false,
}, },
isRecommend: { isRecommend: {
type:Boolean, type: Boolean,
default:true, default: true,
}, },
boardId: { boardId: {
type: Number, type: Number,
required: true, required: true,
}, },
commentId: { commentId: {
type: [Number, null], type: [Number, null],
default: null, default: null,
}, },
likeCount: { likeCount: {
type: Number, type: Number,
default: 0, default: 0,
}, },
dislikeCount: { dislikeCount: {
type: Number, type: Number,
default: 0, default: 0,
}, },
}); });
const emit = defineEmits(['updateReaction']); const emit = defineEmits(['updateReaction']);
const likeClicked = ref(props.likeClicked); const likeClicked = ref(props.likeClicked);
const dislikeClicked = ref(props.dislikeClicked); const dislikeClicked = ref(props.dislikeClicked);
const likeCount = computed(() => props.comment?.likeCount ?? props.likeCount); const likeCount = computed(() => props.comment?.likeCount ?? props.likeCount);
const dislikeCount = computed(() => props.comment?.dislikeCount ?? props.dislikeCount); const dislikeCount = computed(() => props.comment?.dislikeCount ?? props.dislikeCount);
const handleLike = () => { const handleLike = () => {
const isLike = !likeClicked.value; const isLike = !likeClicked.value;
const isDislike = false; const isDislike = false;
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike }); emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
likeClicked.value = isLike; likeClicked.value = isLike;
dislikeClicked.value = false; dislikeClicked.value = false;
}; };
const handleDislike = () => { const handleDislike = () => {
const isDislike = !dislikeClicked.value; const isDislike = !dislikeClicked.value;
const isLike = false; const isLike = false;
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
dislikeClicked.value = isDislike;
likeClicked.value = false;
};
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
dislikeClicked.value = isDislike;
likeClicked.value = false;
};
</script> </script>
<style scoped> <style scoped>
.btn + .btn { .btn + .btn {
margin-left: 5px; margin-left: 5px;
} }
.num { .num {
margin-left: 5px; margin-left: 5px;
} }
.btn-label-danger.clicked { .btn-label-danger.clicked {
background-color: #e6381a; background-color: #e6381a;
} }
.btn-label-danger.clicked i, .btn-label-danger.clicked i,
.btn-label-danger.clicked span { .btn-label-danger.clicked span {
color: #fff; color: #fff;
} }
.btn-label-primary.clicked { .btn-label-primary.clicked {
background-color: #5f61e6; background-color: #5f61e6;
} }
.btn-label-primary.clicked i, .btn-label-primary.clicked i,
.btn-label-primary.clicked span { .btn-label-primary.clicked span {
color : #fff; color: #fff;
} }
.btn { .btn {
width: 55px; width: 55px;
height: 30px; /* height: 30px; */
} }
.btn.big { .btn.big {
width: 70px; width: 70px;
height: 70px; height: 70px;
font-size: 18px; font-size: 18px;
} }
@media screen and (max-width:450px) { @media screen and (max-width: 450px) {
.btn { .btn {
width: 50px; width: 50px;
height: 20px; height: 20px;
font-size: 12px; font-size: 12px;
}
} }
}
</style> </style>

View File

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

View File

@ -18,7 +18,7 @@
</button> </button>
<!-- 저장 버튼 --> <!-- 저장 버튼 -->
<div class="save-button-container"> <div class="save-button-container">
<button class="btn-success" @click="addVacationRequests" <button class="vac-btn-success" @click="addVacationRequests"
:class="{ active: !isDisabled, disabled: isDisabled }"> :class="{ active: !isDisabled, disabled: isDisabled }">
</button> </button>

View File

@ -1,5 +1,18 @@
<template> <template>
<ul class="cate-list list-unstyled d-flex flex-wrap mb-0"> <ul class="cate-list list-unstyled d-flex flex-wrap mb-0">
<li v-if="showAll" class="mt-2 me-2">
<button
type="button"
class="btn"
:class="{
'btn-outline-primary': selectedCategory !== 'all',
'btn-primary': selectedCategory === 'all'
}"
@click="selectCategory('all')"
>
All
</button>
</li>
<li v-for="category in lists" :key="category.value" class="mt-2 me-2"> <li v-for="category in lists" :key="category.value" class="mt-2 me-2">
<button <button
type="button" type="button"
@ -18,7 +31,7 @@
</template> </template>
<script setup> <script setup>
import { defineProps, ref } from 'vue'; import { defineProps, ref, watch } from 'vue';
// lists prop // lists prop
const props = defineProps({ const props = defineProps({
@ -26,29 +39,38 @@ const props = defineProps({
type: Array, type: Array,
required: true, required: true,
}, },
showAll: {
type: Boolean,
required: false,
},
selectedCategory: {
type: [String, Number],
default: null,
required: false,
},
}); });
// //
const selectedCategory = ref(null); const selectedCategory = ref(props.selectedCategory);
const emit = defineEmits();
const emit = defineEmits(['update:data']);
const selectCategory = (cate) => { const selectCategory = (cate) => {
selectedCategory.value = selectedCategory.value === cate ? null : cate; selectedCategory.value = selectedCategory.value === cate ? null : cate;
emit('update:data', selectedCategory.value); emit('update:data', selectedCategory.value);
}; };
watch(() => props.selectedCategory, (newVal) => {
selectedCategory.value = newVal;
});
</script> </script>
<style scoped> <style scoped>
@media (max-width: 768px) { @media (max-width: 768px) {
.cate-list { .cate-list {
overflow-x: scroll; overflow-x: scroll;
flex-wrap: nowrap !important; flex-wrap: nowrap !important;
li {
flex: 0 0 auto;
}
} }
} }
</style> </style>

View File

@ -114,7 +114,6 @@
// , HTML // , HTML
if (props.initialData) { if (props.initialData) {
console.log(props.initialData);
quillInstance.setContents(JSON.parse(props.initialData)); quillInstance.setContents(JSON.parse(props.initialData));
} }
@ -126,7 +125,6 @@
// //
quillInstance.on('text-change', (delta, oldDelta, source) => { quillInstance.on('text-change', (delta, oldDelta, source) => {
emit('update:data', quillInstance.getContents());
delta.ops.forEach(op => { delta.ops.forEach(op => {
if (op.insert && typeof op.insert === 'object' && op.insert.image) { if (op.insert && typeof op.insert === 'object' && op.insert.image) {
const imageUrl = op.insert.image; // URL const imageUrl = op.insert.image; // URL
@ -135,7 +133,9 @@
checkForDeletedImages(); // checkForDeletedImages(); //
} }
}); });
emit('update:data', quillInstance.getContents());
}); });
// //
async function selectLocalImage() { async function selectLocalImage() {
const input = document.createElement('input'); const input = document.createElement('input');
@ -165,17 +165,43 @@
} }
}; };
} }
// //
async function uploadImageToServer(formData) { async function uploadImageToServer(formData) {
try { try {
const response = await $api.post('quilleditor/upload', formData, { isFormData: true }); // Make the POST request to upload the image
const imageUrl = response.data.data; const response = await $api.post('quilleditor/upload', formData, { isFormData: true });
return imageUrl; // URL
} catch (error) { // Check if the response contains the expected data
toastStore.onToast('잠시후 다시 시도해주세요.', 'e'); if (response.data && response.data.data) {
throw error; 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() { function checkForDeletedImages() {
@ -190,6 +216,7 @@
} }
}); });
</script> </script>
<style> <style>
@import 'quill/dist/quill.snow.css'; @import 'quill/dist/quill.snow.css';
.ql-editor { .ql-editor {

View File

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

View File

@ -14,101 +14,98 @@
:placeholder="title" :placeholder="title"
:disabled="disabled" :disabled="disabled"
:min="min" :min="min"
autocomplete="off"
@focusout="$emit('focusout', modelValue)" @focusout="$emit('focusout', modelValue)"
@input="handleInput" @input="handleInput"
/> />
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''"> <div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div>
{{ title }} 확인해주세요.
</div>
<!-- 카테고리 중복 --> <!-- 카테고리 중복 -->
<div class="invalid-feedback" :class="isCateAlert ? 'd-block' : ''"> <div class="invalid-feedback" :class="isCateAlert ? 'd-block' : ''">카테고리 중복입니다.</div>
카테고리 중복입니다.
</div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
// Props // Props
const props = defineProps({ const props = defineProps({
title: { title: {
type: String, type: String,
default: '라벨', default: '라벨',
required: true, required: true,
}, },
name: { name: {
type: String, type: String,
default: 'nameplz', default: 'nameplz',
required: true, required: true,
}, },
isEssential: { isEssential: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
type: { type: {
type: String, type: String,
default: 'text', default: 'text',
}, },
modelValue: { modelValue: {
type: String, type: String,
default: '', default: '',
}, },
maxlength: { maxlength: {
type: Number, type: Number,
default: 30, default: 30,
}, },
isAlert: { isAlert: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isCateAlert : { isCateAlert: {
type :Boolean, type: Boolean,
default: false, default: false,
}, },
isLabel : { isLabel: {
type: Boolean, type: Boolean,
default: true, default: true,
required: false, required: false,
}, },
disabled: { disabled: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
min: { min: {
type: String, type: String,
default: '', default: '',
required: false, required: false,
} },
}); });
// Emits // Emits
const emits = defineEmits(['update:modelValue', 'focusout', 'update:alert']); const emits = defineEmits(['update:modelValue', 'focusout', 'update:alert']);
// `inputValue`
const inputValue = ref(props.modelValue);
// `inputValue` //
const inputValue = ref(props.modelValue); watch(inputValue, newValue => {
emits('update:modelValue', newValue);
});
// //
watch(inputValue, (newValue) => { watch(
emits('update:modelValue', newValue); () => props.modelValue,
}); newValue => {
if (inputValue.value !== newValue) {
inputValue.value = newValue;
}
},
);
// const handleInput = event => {
watch(() => props.modelValue, (newValue) => { const newValue = event.target.value.slice(0, props.maxlength);
if (inputValue.value !== newValue) {
inputValue.value = newValue;
}
});
const handleInput = (event) => {
const newValue = event.target.value.slice(0, props.maxlength);
if (newValue.trim() !== '') {
emits('update:alert', false);
}
};
if (newValue.trim() !== '') {
emits('update:alert', false);
}
};
</script> </script>

View File

@ -2,7 +2,7 @@
<div class="mb-2" :class="isRow ? 'row' : ''"> <div class="mb-2" :class="isRow ? 'row' : ''">
<label :for="name" class="col-md-2 col-form-label" :class="isLabel ? 'd-block' : 'd-none'"> <label :for="name" class="col-md-2 col-form-label" :class="isLabel ? 'd-block' : 'd-none'">
{{ title }} {{ title }}
<span :class="isEssential ? 'link-danger' : 'none'">*</span> <span v-if="isEssential" class="link-danger">*</span>
</label> </label>
<div :class="isRow ? 'col-md-10' : 'col-md-12'" class="d-flex gap-2 align-items-center"> <div :class="isRow ? 'col-md-10' : 'col-md-12'" class="d-flex gap-2 align-items-center">
<select class="form-select" :id="name" v-model="selectData" :disabled="disabled" :style="isColor ? { color: selected } : {}" @blur="$emit('blur')"> <select class="form-select" :id="name" v-model="selectData" :disabled="disabled" :style="isColor ? { color: selected } : {}" @blur="$emit('blur')">

View File

@ -8,7 +8,7 @@
{{ title }} {{ title }}
</h5> </h5>
<p v-if="isProjectExpired" class="btn-icon btn-danger rounded-2"><i class='bx bx-power-off'></i></p> <p v-if="isProjectExpired" class="btn-icon btn-danger rounded-2"><i class='bx bx-power-off'></i></p>
<div v-if="!isProjectExpired"> <div v-if="!isProjectExpired" class="d-flex gap-1">
<EditBtn @click.stop="openEditModal" /> <EditBtn @click.stop="openEditModal" />
<DeleteBtn v-if="isProjectCreator" @click.stop="handleDelete" class="ms-1"/> <DeleteBtn v-if="isProjectCreator" @click.stop="handleDelete" class="ms-1"/>
</div> </div>
@ -128,6 +128,7 @@
title="종료일" title="종료일"
type="date" type="date"
name="endDay" name="endDay"
:min="todays"
:modelValue="selectedProject.PROJCTEND" :modelValue="selectedProject.PROJCTEND"
@update:modelValue="selectedProject.PROJCTEND = $event" @update:modelValue="selectedProject.PROJCTEND = $event"
/> />
@ -160,7 +161,7 @@
</template> </template>
<script setup> <script setup>
import { defineProps, onMounted, ref, computed, watch } from 'vue'; import { defineProps, onMounted, ref, computed, watch, inject } from 'vue';
import UserList from '@c/user/UserList.vue'; import UserList from '@c/user/UserList.vue';
import CenterModal from '@c/modal/CenterModal.vue'; import CenterModal from '@c/modal/CenterModal.vue';
import $api from '@api'; import $api from '@api';
@ -253,6 +254,12 @@ const isProjectCreator = computed(() => {
return user.value?.id === props.projctCreatorId; return user.value?.id === props.projctCreatorId;
}); });
// dayjs
const dayjs = inject('dayjs');
// YYYY-MM-DD
const todays = dayjs().format('YYYY-MM-DD');
// ( ) // ( )
const isProjectExpired = computed(() => { const isProjectExpired = computed(() => {
if (!props.enddate) return false; if (!props.enddate) return false;
@ -356,19 +363,6 @@ const hasChanges = computed(() => {
selectedProject.value.PROJCTDES !== props.description || selectedProject.value.PROJCTDES !== props.description ||
selectedProject.value.PROJCTCOL !== props.projctCol; selectedProject.value.PROJCTCOL !== props.projctCol;
}); });
//
watch(
() => selectedProject.value,
() => {
const start = new Date(selectedProject.value.PROJCTSTR);
const end = new Date(selectedProject.value.PROJCTEND);
if (end < start) {
selectedProject.value.PROJCTEND = selectedProject.value.PROJCTSTR;
}
},
{ deep: true, flush: 'post' }
);
// //
const handleUpdate = () => { const handleUpdate = () => {

View File

@ -1,7 +1,7 @@
<template> <template>
<div v-if="isOpen" class="vac-modal-dialog" @click.self="closeModal"> <div v-if="isOpen" class="vac-modal-dialog" @click.self="closeModal">
<div class="vac-modal-content p-5 modal-scroll"> <div class="vac-modal-content p-5 modal-scroll">
<h5 class="vac-modal-title">📅 연차 사용 내역</h5> <h5 class="vac-modal-title">📅 연차 상세 내역</h5>
<button class="close-btn" @click="closeModal"></button> <button class="close-btn" @click="closeModal"></button>
<!-- 연차 목록 --> <!-- 연차 목록 -->
<div class="vac-modal-body" v-if="mergedVacations.length > 0"> <div class="vac-modal-body" v-if="mergedVacations.length > 0">
@ -26,8 +26,8 @@
</ol> </ol>
</div> </div>
<!-- 연차 데이터 없음 --> <!-- 연차 데이터 없음 -->
<p v-else class="text-sm-center mt-10 text-gray"> <p v-else class="text-sm-center mt-10 text-gray vac-modal-title">
🚫 사용한 연차가 없습니다. 🚫 연차 내역이 없습니다.
</p> </p>
</div> </div>
</div> </div>
@ -58,19 +58,15 @@ const emit = defineEmits(["close"]);
// (,) // (,)
let globalCounter = 0; let globalCounter = 0;
const usedVacations = computed(() => { const usedVacations = computed(() => {
const result = []; return props.myVacations.flatMap((v) => {
props.myVacations.forEach((v) => {
const count = v.used_quota || 1; const count = v.used_quota || 1;
for (let i = 0; i < count; i++) { return Array.from({ length: count }, (_, i) => ({
result.push({ ...v,
...v, category: "used",
category: "used", code: v.LOCVACTYP,
code: v.LOCVACTYP, _expandIndex: globalCounter++,
_expandIndex: globalCounter++, }));
});
}
}); });
return result;
}); });
// //
@ -114,7 +110,7 @@ const mergedVacations = computed(() => {
// //
const closeModal = () => { const closeModal = () => {
emit("close"); emit("close");
}; };
</script> </script>
<style scoped> <style scoped>

View File

@ -69,6 +69,7 @@
:type="'date'" :type="'date'"
name="endDay" name="endDay"
:modelValue="endDay" :modelValue="endDay"
:min = "today"
@update:modelValue="endDay = $event" @update:modelValue="endDay = $event"
/> />

View File

@ -94,8 +94,7 @@
:is-common="true" :is-common="true"
:is-color="true" :is-color="true"
:data="colorList" :data="colorList"
@update:data="color = $event" @update:data="handleColorUpdate"
@blur="checkColorDuplicate"
class="w-50" class="w-50"
/> />
</div> </div>
@ -136,6 +135,7 @@
@update:data="handleAddressUpdate" @update:data="handleAddressUpdate"
@update:alert="addressAlert = $event" @update:alert="addressAlert = $event"
:value="address" :value="address"
:disabled="true"
/> />
<UserFormInput <UserFormInput
@ -143,7 +143,7 @@
name="phone" name="phone"
:isEssential="true" :isEssential="true"
:is-alert="phoneAlert" :is-alert="phoneAlert"
@update:data="phone = $event" @update:data="phone = $event.replace(/[^0-9.]/g, '').replace(/(\..*)\./g, '$1')"
@update:alert="phoneAlert = $event" @update:alert="phoneAlert = $event"
@blur="checkPhoneDuplicate" @blur="checkPhoneDuplicate"
:maxlength="11" :maxlength="11"
@ -298,7 +298,6 @@
} }
}; };
// //
const checkColorDuplicate = async () => { const checkColorDuplicate = async () => {
const response = await $api.get(`/user/checkColor?memberCol=${color.value}`); const response = await $api.get(`/user/checkColor?memberCol=${color.value}`);
@ -312,6 +311,14 @@
} }
}; };
const handleColorUpdate = async (newColor) => {
color.value = newColor;
colorError.value = '';
colorErrorAlert.value = false;
await checkColorDuplicate();
}
// //
const handleSubmit = async () => { const handleSubmit = async () => {
@ -366,4 +373,3 @@
}; };
</script> </script>
<style></style>

View File

@ -1,10 +1,11 @@
<template> <template>
<div class="card-body d-flex justify-content-center m-n5"> <div class="card-body d-flex justify-content-center m-n5">
<ul class="list-unstyled profile-list"> <ul class="profile-list">
<li <li
v-for="(user, index) in sortedUserList" v-for="(user, index) in sortedUserList"
:key="index" :key="index"
:class="{ disabled: user.disabled }" class="profile-item"
:class="{ newRow: (index + 1) % 4 === 0 }"
@click="$emit('profileClick', user)" @click="$emit('profileClick', user)"
data-bs-placement="top" data-bs-placement="top"
:aria-label="user.MEMBERSEQ" :aria-label="user.MEMBERSEQ"
@ -69,12 +70,18 @@ nextTick(() => {
}); });
const sortedUserList = computed(() => { const sortedUserList = computed(() => {
if (!employeeId.value) return userList.value; if (!employeeId.value) return [];
const myProfile = userList.value.find(user => user.MEMBERSEQ === employeeId.value);
const otherUsers = userList.value.filter(user => user.MEMBERSEQ !== employeeId.value); //
return myProfile ? [myProfile, ...otherUsers] : userList.value; const nonAdminUsers = userList.value.filter(user => user.MEMBERROL !== "ROLE_ADMIN");
const myProfile = nonAdminUsers.find(user => user.MEMBERSEQ === employeeId.value);
const otherUsers = nonAdminUsers.filter(user => user.MEMBERSEQ !== employeeId.value);
return myProfile ? [myProfile, ...otherUsers] : otherUsers;
}); });
const getUserProfileImage = (profilePath) => const getUserProfileImage = (profilePath) =>
profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile; profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile;
@ -85,20 +92,20 @@ const showImage = (event) => (event.target.style.visibility = "visible");
const profileSize = computed(() => { const profileSize = computed(() => {
const totalUsers = userList.value.length; const totalUsers = userList.value.length;
if (windowWidth.value >= 1650) { if (windowWidth.value >= 1850) {
if (totalUsers <= 10) return "68px"; if (totalUsers <= 10) return "80px";
if (totalUsers <= 15) return "55px"; if (totalUsers <= 15) return "60px";
return "45px"; return "45px";
} else if (windowWidth.value >= 1300) { } else if (windowWidth.value >= 1500) {
if (totalUsers <= 10) return "45px"; if (totalUsers <= 10) return "60px";
if (totalUsers <= 15) return "40px"; if (totalUsers <= 15) return "40px";
return "30px"; return "30px";
} else if (windowWidth.value >= 1024) { } else if (windowWidth.value >= 900) {
if (totalUsers <= 10) return "40px"; if (totalUsers <= 10) return "48px";
if (totalUsers <= 15) return "30px"; if (totalUsers <= 15) return "30px";
return "20px"; return "20px";
} else { } else {
return "20px"; return "35px";
} }
}); });

View File

@ -1,5 +1,5 @@
<template> <template>
<div class="card mb-6"> <div class="card mb-6" :class="{'disabled-class': data.localVote.LOCVOTDDT}" >
<div class="card-body" v-if="!data.localVote.LOCVOTDEL" > <div class="card-body" v-if="!data.localVote.LOCVOTDEL" >
<h5 class="card-title mb-1"> <h5 class="card-title mb-1">
<div class="list-unstyled users-list d-flex align-items-center gap-1"> <div class="list-unstyled users-list d-flex align-items-center gap-1">
@ -7,6 +7,7 @@
class="rounded-circle user-avatar border border-3 w-px-40" class="rounded-circle user-avatar border border-3 w-px-40"
:src="`${baseUrl}upload/img/profile/${data.localVote.MEMBERPRF}`" :src="`${baseUrl}upload/img/profile/${data.localVote.MEMBERPRF}`"
:style="`border-color: ${data.localVote.usercolor} !important;`" :style="`border-color: ${data.localVote.usercolor} !important;`"
@error="$event.target.src = '/img/icons/icon.png'"
alt="user" alt="user"
/> />
@ -17,7 +18,7 @@
</div> </div>
<div class="add-btn d-flex align-items-center"> <div class="add-btn d-flex align-items-center">
<!-- 투표완료시 --> <!-- 투표완료시 -->
<i v-if="data.yesVotetotal == '1'" class="bx bxs-check-circle link-success"></i> <i v-if="yesVotetotal != '0'" class="bx bxs-check-circle link-success"></i>
<!-- 투표작성자만 수정/삭제/종료 가능 --> <!-- 투표작성자만 수정/삭제/종료 가능 -->
<div v-if="userStore.user.id === data.localVote.LOCVOTREG"> <div v-if="userStore.user.id === data.localVote.LOCVOTREG">
<button <button
@ -26,8 +27,9 @@
class="bx btn btn-danger" class="bx btn btn-danger"
@click="endBtn(data.localVote.LOCVOTSEQ)" @click="endBtn(data.localVote.LOCVOTSEQ)"
>종료</button> >종료</button>
<DeleteBtn @click="voteDelete(data.localVote.LOCVOTSEQ)" /> <DeleteBtn v-if="!data.localVote.LOCVOTDDT" @click="voteDelete(data.localVote.LOCVOTSEQ)" />
</div> </div>
<p v-if="data.localVote.LOCVOTDDT" class="btn-icon btn-danger rounded-2"><i class="bx bx-power-off"></i></p>
</div> </div>
</div> </div>
</div> </div>
@ -36,21 +38,23 @@
<h5 class="mb-1">{{ data.localVote.LOCVOTTTL }}</h5> <h5 class="mb-1">{{ data.localVote.LOCVOTTTL }}</h5>
<small >{{ data.localVote.formatted_LOCVOTRDT }} ~ {{ data.localVote.formatted_LOCVOTEDT }}</small> <small >{{ data.localVote.formatted_LOCVOTRDT }} ~ {{ data.localVote.formatted_LOCVOTEDT }}</small>
<!-- 투표안했을시--> <!-- 투표안했을시-->
<div v-if="data.localVote.LOCVOTDDT && data.voteResult.length == 0"> <div v-if="data.localVote.LOCVOTDDT && voteResult == 0">
<small class="text-primary text-uppercase">투표 결과없음 (😂아무도 투표하지 않았습니다)</small> <small class="text-primary text-uppercase">투표 결과없음 (😂아무도 투표하지 않았습니다)</small>
</div> </div>
<div v-else> <div v-else>
<vote-card-check <vote-card-check
v-if="data.yesVotetotal == 0" v-if="yesVotetotal == 0 && !data.localVote.LOCVOTDDT"
@addContents="addContents" @addContents="addContents"
@checkedNames="checkedNames" @checkedNames="checkedNames"
:data="data.voteDetails" :data="data.voteDetails"
:voteInfo="data.localVote" :voteInfo="data.localVote"
:total="data.voteDetails.length "/> :total="data.voteDetails.length "/>
<small v-if="yesVotetotal != 0 && !data.localVote.LOCVOTDDT">투표 완료 : 종료시 투표 결과가 나타납니다.</small>
<!-- 투표 결과 --> <!-- 투표 결과 -->
<div v-if="data.localVote.LOCVOTDDT" class="mt-3"> <div v-if="data.localVote.LOCVOTDDT" class="mt-3">
<vote-result-list :data="data.voteResult" @randomList="randomList" :randomResultNum="data.localVote.LOCVOTRES"/> <vote-result-list :data="data.voteDetails" @randomList="randomList" :randomResultNum="data.localVote.LOCVOTRES"/>
</div> </div>
<!-- 투표완/미완 인원 --> <!-- 투표완/미완 인원 -->
<vote-user-list <vote-user-list
@ -84,6 +88,13 @@ const props = defineProps({
required: false, required: false,
}, },
}); });
const voteResult = computed(() => {
return props.data.voteDetails.reduce((sum, item) => sum + item.VOTE_COUNT, 0);
});
const yesVotetotal = computed(() => {
return props.data.voteDetails.reduce((sum, item) => sum + item.yesvote, 0);
});
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, ''); const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const userStore = useUserInfoStore(); const userStore = useUserInfoStore();
const currentDate = new Date(); const currentDate = new Date();

View File

@ -10,27 +10,26 @@
:selectedValues="checkedNames" :selectedValues="checkedNames"
@update:selectedValues="updateCheckedNames" @update:selectedValues="updateCheckedNames"
/> />
<div v-if="voteInfo.LOCVOTADD ==='1' && index === data.length - 1" class="d-flex align-items-center"> <div v-if="voteInfo.LOCVOTADD ==='1' && index === data.length - 1">
<div class="d-flex flex-column gap-2"> <div v-for="(item, index) in itemList" :key="index" class="d-flex align-items-start mt-2">
<div v-for="(item, index) in itemList" :key="index" class="d-flex align-items-start"> <div class="flex-grow-1 me-2 ">
<form-input <form-input
class="flex-grow-1 me-2"
:title="'항목 ' + (index + data.length + 1)" :title="'항목 ' + (index + data.length + 1)"
:name="'content' + index" :name="'content' + index"
:is-essential="false" :is-essential="false"
:is-alert="contentAlerts[index]" :is-alert="contentAlerts[index]"
v-model="item.content" v-model="item.content"
/> />
<link-input v-model="item.url" /> <link-input v-model="item.url" />
<delete-btn @click="removeItem(index)" class="ms-2" />
</div> </div>
<div class="mb-4 d-flex justify-content"> <delete-btn @click="removeItem(index)" />
<plus-btn @click="addItem" :disabled="total >= 10" class="mb-3" /> </div>
<div class="mb-4 d-flex justify-content mt-3">
<plus-btn @click="addItem" :disabled="total >= 10" class="mb-2" />
<button class="btn btn-primary btn-icon mb-3" @click="addContentSave(item.LOCVOTSEQ)" :disabled="isSaveDisabled"> <button class="btn btn-primary btn-icon mb-3" @click="addContentSave(item.LOCVOTSEQ)" :disabled="isSaveDisabled">
<i class="bx bx-check"></i> <i class="bx bx-check"></i>
</button> </button>
</div> </div>
</div>
</div> </div>
</div> </div>
@ -89,7 +88,9 @@ const updateCheckedNames = (newValues) => {
checkedNames.value = newValues; checkedNames.value = newValues;
}; };
const selectVote = () =>{ const selectVote = () =>{
emit('checkedNames',checkedNames.value); if(checkedNames.value != ''){
emit('checkedNames',checkedNames.value);
}
} }

View File

@ -40,7 +40,6 @@ const emit = defineEmits(["update:selectedValues"]);
const handleChange = (event) => { const handleChange = (event) => {
const value = event.target.value; const value = event.target.value;
let updatedValues = []; let updatedValues = [];
// //
if (props.multiIs === "1") { if (props.multiIs === "1") {
updatedValues = event.target.checked updatedValues = event.target.checked

View File

@ -24,7 +24,7 @@ const props = defineProps({
}, },
}); });
const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','voteDelete']); const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','voteDelete','randomList']);
const addContents = (itemList ,voteId) =>{ const addContents = (itemList ,voteId) =>{
emit('addContents',itemList ,voteId); emit('addContents',itemList ,voteId);
} }

View File

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

View File

@ -10,7 +10,7 @@
:titleValue="item.WRDDICTTL" :titleValue="item.WRDDICTTL"
:contentValue="item.WRDDICCON" :contentValue="item.WRDDICCON"
:isDisabled="userStore.user.role !== 'ROLE_ADMIN'" :isDisabled="userStore.user.role !== 'ROLE_ADMIN'"
/> />
<div v-else> <div v-else>
<input <input
@ -65,7 +65,8 @@
</div> </div>
<div class="edit-btn" v-if="userStore.user.role !== 'ROLE_ADMIN'"> <div class="edit-btn" v-if="userStore.user.role !== 'ROLE_ADMIN'">
<EditBtn ref="writeButton" @click="writeStore.toggleItem(item.WRDDICSEQ)" :isToggleEnabled="true"/> <EditBtn ref="writeButton" @click="writeStore.toggleItem(item.WRDDICSEQ)" :isToggleEnabled="true"
:isActive="writeStore.activeItemId === item.WRDDICSEQ"/>
</div> </div>
</li> </li>
</template> </template>
@ -124,7 +125,7 @@ const editWord = (data) => {
}) })
.then((res) => { .then((res) => {
if (res.data.data === 1) { if (res.data.data === 1) {
toastStore.onToast('용어가 수정되었습니다.', 's'); toastStore.onToast('용어가 수정되었습니다.', 's');
writeStore.closeAll(); writeStore.closeAll();
if (writeButton.value) { if (writeButton.value) {
writeButton.value.resetButton(); writeButton.value.resetButton();

View File

@ -11,10 +11,11 @@
@change="onChange" @change="onChange"
:value="formValue" :value="formValue"
:disabled="isDisabled" :disabled="isDisabled"
:is-essential="false"
/> />
</div> </div>
<div class="col-2 btn-margin" v-if="!isDisabled"> <div class="col-2 btn-margin" v-if="!isDisabled">
<PlusBtn @click="toggleInput" /> <PlusBtn @click="toggleInput"/>
</div> </div>
</div> </div>
@ -41,15 +42,11 @@
:modelValue="titleValue" :modelValue="titleValue"
@update:modelValue="wordTitle = $event" @update:modelValue="wordTitle = $event"
:disabled="isDisabled" :disabled="isDisabled"
@keyup="ValidHandler('title')"
/> />
</div> </div>
<div> <div>
<QEditor <QEditor @keyup="ValidHandler('content')" @update:data="handleContentUpdate" @update:imageUrls="imageUrls = $event" :is-alert="wordContentAlert" :initialData="contentValue"/>
@update:data="content = $event"
@update:imageUrls="imageUrls = $event"
:is-alert="wordContentAlert"
:initialData="contentValue"
/>
<div class="text-end mt-5"> <div class="text-end mt-5">
<button class="btn btn-primary" @click="saveWord"> <button class="btn btn-primary" @click="saveWord">
<i class="bx bx-check"></i> <i class="bx bx-check"></i>
@ -59,153 +56,183 @@
</template> </template>
<script setup> <script setup>
import { defineProps, computed, ref, defineEmits } from 'vue'; import { defineProps, computed, ref, defineEmits } from 'vue';
import QEditor from '@/components/editor/QEditor.vue'; import QEditor from '@/components/editor/QEditor.vue';
import FormInput from '@/components/input/FormInput.vue'; import FormInput from '@/components/input/FormInput.vue';
import FormSelect from '@/components/input/FormSelect.vue'; import FormSelect from '@/components/input/FormSelect.vue';
import PlusBtn from '../button/PlusBtn.vue'; import PlusBtn from '../button/PlusBtn.vue';
const emit = defineEmits(['close', 'addCategory', 'addWord']); const emit = defineEmits(['close','addCategory','addWord']);
// //
const wordTitle = ref(''); const wordTitle = ref('');
const addCategory = ref(''); const addCategory = ref('');
const content = ref(''); const content = ref('');
const imageUrls = ref([]); const imageUrls = ref([]);
// Vaildation // Vaildation
const wordTitleAlert = ref(false); const wordTitleAlert = ref(false);
const wordContentAlert = ref(false); const wordContentAlert = ref(false);
const addCategoryAlert = ref(false); const addCategoryAlert = ref(false);
// //
const selectCategory = ref(''); const selectCategory = ref('');
// //
const computedTitle = computed(() => (wordTitle.value === '' ? props.titleValue : wordTitle.value)); const computedTitle = computed(() =>
wordTitle.value === '' ? props.titleValue : wordTitle.value
);
// //
const selectedCategory = computed(() => (selectCategory.value === '' ? props.formValue : selectCategory.value)); const selectedCategory = computed(() =>
selectCategory.value === '' ? props.formValue : selectCategory.value
// ref );
const categoryInputRef = ref(null);
const props = defineProps({ // ref
dataList: { const categoryInputRef = ref(null);
type: Array,
default: () => [],
},
NumValue: {
type: Number,
},
formValue: {
type: [String, Number],
},
titleValue: {
type: String,
},
contentValue: {
type: String,
},
isDisabled: {
type: Boolean,
default: false,
},
});
// const props = defineProps({
const showInput = ref(false); dataList: {
type: Array,
default: () => []
},
NumValue : {
type: Number
},
formValue : {
type:[String, Number]
},
titleValue : {
type:String,
},contentValue : {
type:String,
},
isDisabled: {
type: Boolean,
default: false
}
});
// //
const toggleInput = () => { const showInput = ref(false);
showInput.value = !showInput.value;
//
const toggleInput = () => {
showInput.value = !showInput.value;
};
const onChange = (newValue) => {
selectCategory.value = newValue.target.value;
};
const ValidHandler = (field) => {
if(field == 'title'){
wordTitleAlert.value = false;
}
if(field == 'content'){
wordContentAlert.value = false;
}
}
const handleContentUpdate = (newContent) => {
content.value = newContent;
ValidHandler("content"); //
};
//
const saveWord = () => {
let valid = true;
//validation
let computedTitleTrim;
if(computedTitle.value != undefined){
computedTitleTrim = computedTitle.value.trim()
}
//
if(computedTitleTrim == undefined || computedTitleTrim == ''){
wordTitleAlert.value = true;
valid = false;
} else {
wordTitleAlert.value = false;
}
//
let inserts = [];
if (inserts.length === 0 && content.value?.ops?.length > 0) {
inserts = content.value.ops.map(op =>
typeof op.insert === 'string' ? op.insert.trim() : op.insert
);
}
//
if(content.value == '' || inserts.join('') === ''){
wordContentAlert.value = true;
valid = false;
}else{
wordContentAlert.value = false;
}
const wordData = {
id: props.NumValue || null,
title: computedTitle.value,
category: selectedCategory.value,
content: content.value,
}; };
if(valid){
emit('addWord', wordData, addCategory.value === ''
? (isNaN(selectedCategory.value) ? selectedCategory.value : Number(selectedCategory.value))
: addCategory.value);
}
}
const onChange = newValue => {
selectCategory.value = newValue.target.value;
};
// // focusout
const saveWord = () => { const handleCategoryFocusout = (value) => {
//validation const valueTrim = value.trim();
let computedTitleTrim;
if (computedTitle.value != undefined) { const existingCategory = props.dataList.find(item => item.label === valueTrim);
computedTitleTrim = computedTitle.value.trim();
}
// //
if (computedTitleTrim == undefined || computedTitleTrim == '') { if(valueTrim == ''){
wordTitleAlert.value = true; addCategoryAlert.value = true;
return;
} else {
wordTitleAlert.value = false;
}
// // focus
let inserts = []; setTimeout(() => {
if (inserts.length === 0 && content.value?.ops?.length > 0) { const inputElement = categoryInputRef.value?.$el?.querySelector('input');
inserts = content.value.ops.map(op => (typeof op.insert === 'string' ? op.insert.trim() : op.insert)); if (inputElement) {
} inputElement.focus();
}
}, 0);
//
if (content.value == '' || inserts.join('') === '') {
wordContentAlert.value = true;
return;
}
const wordData = { }else if (existingCategory) {
id: props.NumValue || null, addCategoryAlert.value = true;
title: computedTitle.value,
category: selectedCategory.value,
content: content.value,
};
emit('addWord', wordData, addCategory.value); // focus
}; setTimeout(() => {
const inputElement = categoryInputRef.value?.$el?.querySelector('input');
if (inputElement) {
inputElement.focus();
}
}, 0);
// focusout } else {
const handleCategoryFocusout = value => { addCategoryAlert.value = false;
const valueTrim = value.trim(); }
};
const existingCategory = props.dataList.find(item => item.label === valueTrim);
//
if (valueTrim == '') {
addCategoryAlert.value = true;
// focus
setTimeout(() => {
const inputElement = categoryInputRef.value?.$el?.querySelector('input');
if (inputElement) {
inputElement.focus();
}
}, 0);
} else if (existingCategory) {
addCategoryAlert.value = true;
// focus
setTimeout(() => {
const inputElement = categoryInputRef.value?.$el?.querySelector('input');
if (inputElement) {
inputElement.focus();
}
}, 0);
} else {
addCategoryAlert.value = false;
}
};
</script> </script>
<style scoped> <style scoped>
.dict-w { .dict-w {
width: 83%; width: 83%;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.btn-margin { .btn-margin {
margin-top: 2.5rem; margin-top: 2.5rem
}
} }
}
</style> </style>

View File

@ -10,7 +10,15 @@
<div class="col-xl-12"> <div class="col-xl-12">
<div class="card-body"> <div class="card-body">
<!-- 제목 입력 --> <!-- 제목 입력 -->
<FormInput title="제목" name="title" :is-essential="true" :is-alert="titleAlert" v-model="title" /> <FormInput
title="제목"
name="title"
:is-essential="true"
:is-alert="titleAlert"
v-model="title"
@update:alert="titleAlert = $event"
@input.once="validateTitle"
/>
<!-- 첨부파일 업로드 --> <!-- 첨부파일 업로드 -->
<FormFile <FormFile
@ -43,7 +51,6 @@
<label for="html5-tel-input" class="col-md-2 col-form-label"> <label for="html5-tel-input" class="col-md-2 col-form-label">
내용 내용
<span class="text-red">*</span> <span class="text-red">*</span>
<div class="invalid-feedback" :class="contentAlert ? 'display-block' : ''">내용을 확인해주세요.</div>
</label> </label>
<div class="col-md-12"> <div class="col-md-12">
<QEditor <QEditor
@ -53,6 +60,7 @@
:initialData="content" :initialData="content"
/> />
</div> </div>
<div v-if="contentAlert" class="invalid-feedback d-block">내용을 확인해주세요.</div>
</div> </div>
<!-- 버튼 --> <!-- 버튼 -->
@ -74,10 +82,15 @@
import QEditor from '@c/editor/QEditor.vue'; import QEditor from '@c/editor/QEditor.vue';
import FormInput from '@c/input/FormInput.vue'; import FormInput from '@c/input/FormInput.vue';
import FormFile from '@c/input/FormFile.vue'; import FormFile from '@c/input/FormFile.vue';
import { ref, onMounted, computed, watch } from 'vue'; import { ref, onMounted, computed, watch, inject } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useToastStore } from '@s/toastStore';
import axios from '@api'; import axios from '@api';
//
const $common = inject('common');
const toastStore = useToastStore();
// //
const title = ref(''); const title = ref('');
const content = ref(''); const content = ref('');
@ -134,51 +147,18 @@
router.push('/board'); router.push('/board');
}; };
// //
const updateBoard = async () => { const checkValidation = () => {
// contentAlert.value = $common.isNotValidContent(content);
if (!title.value) { titleAlert.value = $common.isNotValidInput(title.value);
titleAlert.value = true;
return;
}
titleAlert.value = false;
if (!content.value) { if (titleAlert.value || contentAlert.value || !isFileValid.value) {
contentAlert.value = true; if (titleAlert.value) {
return; title.value = '';
}
contentAlert.value = false;
try {
//
const boardData = {
LOCBRDTTL: title.value,
LOCBRDCON: JSON.stringify(content.value),
LOCBRDSEQ: currentBoardId.value,
};
if (delFileIdx.value && delFileIdx.value.length > 0) {
boardData.delFileIdx = [...delFileIdx.value];
} }
return true;
const fileArray = newFileFilter(attachFiles); } else {
const formData = new FormData(); return false;
Object.entries(boardData).forEach(([key, value]) => {
formData.append(key, value);
});
fileArray.forEach((file, idx) => {
formData.append('files', file);
});
await axios.put(`board/${currentBoardId.value}`, formData, { isFormData: true });
alert('게시물이 수정되었습니다.');
goList();
} catch (error) {
console.error('게시물 수정 중 오류 발생:', error);
alert('게시물 수정에 실패했습니다.');
} }
}; };
@ -219,6 +199,60 @@
}; };
////////////////// fileSection[E] //////////////////// ////////////////// fileSection[E] ////////////////////
/** `content` 변경 감지하여 자동 유효성 검사 실행 */
watch(content, () => {
contentAlert.value = $common.isNotValidContent(content);
});
//
const validateTitle = () => {
titleAlert.value = title.value.trim().length === 0;
};
/** `content` 변경 감지하여 자동 유효성 검사 실행 */
// watch(content, () => {
// contentAlert.value = $common.isNotValidContent(content);
// });
//
const updateBoard = async () => {
if (checkValidation()) return;
try {
//
const boardData = {
LOCBRDTTL: title.value.trim(),
LOCBRDCON: JSON.stringify(content.value),
LOCBRDSEQ: currentBoardId.value,
};
//
if (delFileIdx.value && delFileIdx.value.length > 0) {
boardData.delFileIdx = [...delFileIdx.value];
}
const fileArray = newFileFilter(attachFiles);
const formData = new FormData();
// formData boardData
Object.entries(boardData).forEach(([key, value]) => {
formData.append(key, value);
});
// formData
fileArray.forEach((file, idx) => {
formData.append('files', file);
});
await axios.put(`board/${currentBoardId.value}`, formData, { isFormData: true });
toastStore.onToast('게시물이 수정되었습니다.', 's');
goList();
} catch (error) {
console.error('게시물 수정 중 오류 발생:', error);
toastStore.onToast('게시물 수정에 실패했습니다.');
}
};
// //
onMounted(() => { onMounted(() => {
if (currentBoardId.value) { if (currentBoardId.value) {

View File

@ -25,9 +25,13 @@
<input <input
type="password" type="password"
class="form-control" class="form-control"
autocomplete="off"
v-model="password" v-model="password"
placeholder="비밀번호 입력" placeholder="비밀번호 입력"
@input="password = password.replace(/\s/g, '')" @input="
password = password.replace(/\s/g, '');
inputCheck();
"
/> />
<button class="btn btn-primary" @click="submitPassword">확인</button> <button class="btn btn-primary" @click="submitPassword">확인</button>
</div> </div>
@ -88,6 +92,7 @@
:unknown="unknown" :unknown="unknown"
:commentAlert="commentAlert" :commentAlert="commentAlert"
:passwordAlert="passwordAlert" :passwordAlert="passwordAlert"
:maxLength="500"
@submitComment="handleCommentSubmit" @submitComment="handleCommentSubmit"
/> />
</div> </div>
@ -130,6 +135,7 @@
import { ref, onMounted, computed } from 'vue'; import { ref, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { useUserInfoStore } from '@/stores/useUserInfoStore'; import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useToastStore } from '@s/toastStore';
import axios from '@api'; import axios from '@api';
// //
const profileName = ref(''); const profileName = ref('');
@ -148,6 +154,7 @@
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const userStore = useUserInfoStore(); const userStore = useUserInfoStore();
const toastStore = useToastStore();
const currentBoardId = ref(Number(route.params.id)); const currentBoardId = ref(Number(route.params.id));
const unknown = computed(() => profileName.value === '익명'); const unknown = computed(() => profileName.value === '익명');
const currentUserId = computed(() => userStore.user.id); // id const currentUserId = computed(() => userStore.user.id); // id
@ -221,6 +228,9 @@
navigateLastPage: 1, navigateLastPage: 1,
}); });
const inputCheck = () => {
passwordAlert.value = '';
};
// //
const fetchBoardDetails = async () => { const fetchBoardDetails = async () => {
try { try {
@ -228,7 +238,6 @@
const data = response.data.data; const data = response.data.data;
profileName.value = data.author || '익명'; profileName.value = data.author || '익명';
console.log(data.author);
authorId.value = data.authorId; authorId.value = data.authorId;
boardTitle.value = data.title || '제목 없음'; boardTitle.value = data.title || '제목 없음';
boardContent.value = data.content || ''; boardContent.value = data.content || '';
@ -562,6 +571,7 @@
const submitPassword = async () => { const submitPassword = async () => {
if (!password.value.trim()) { if (!password.value.trim()) {
passwordAlert.value = '비밀번호를 입력해주세요.'; passwordAlert.value = '비밀번호를 입력해주세요.';
return; return;
} }
@ -585,18 +595,7 @@
passwordAlert.value = '비밀번호가 일치하지 않습니다.'; passwordAlert.value = '비밀번호가 일치하지 않습니다.';
} }
} catch (error) { } catch (error) {
if (error.reponse && error.reponse.status === 401) passwordAlert.value = '비밀번호가 일치하지 않습니다.'; if (error.response && error.response.status === 401) passwordAlert.value = '비밀번호가 일치하지 않습니다.';
// if (error.response) {
// if (error.response.status === 401) {
// passwordAlert.value = ' .';
// } else {
// passwordAlert.value = error.response.data?.message || ' .';
// }
// } else if (error.request) {
// passwordAlert.value = ' . .';
// } else {
// passwordAlert.value = ' .';
// }
} }
}; };
@ -658,7 +657,7 @@
}); });
if (response.data.code === 200) { if (response.data.code === 200) {
alert('게시물이 삭제되었습니다.'); toastStore.onToast('게시물이 삭제되었습니다.');
router.push({ name: 'BoardList' }); router.push({ name: 'BoardList' });
} else { } else {
alert('삭제 실패: ' + response.data.message); alert('삭제 실패: ' + response.data.message);

View File

@ -23,11 +23,7 @@
<div class="mb-4 d-flex align-items-center"> <div class="mb-4 d-flex align-items-center">
<label class="col-md-2 col-form-label">카테고리 <span class="text-danger">*</span></label> <label class="col-md-2 col-form-label">카테고리 <span class="text-danger">*</span></label>
<div class="d-flex flex-wrap align-items-center mt-3 ms-1"> <div class="d-flex flex-wrap align-items-center mt-3 ms-1">
<div <div v-for="(category, index) in categoryList" :key="index" class="form-check me-3">
v-for="(category, index) in categoryList"
:key="index"
class="form-check me-3"
>
<input <input
class="form-check-input" class="form-check-input"
type="radio" type="radio"
@ -41,9 +37,7 @@
</label> </label>
</div> </div>
</div> </div>
<div class="invalid-feedback" :class="categoryAlert ? 'd-block' : 'd-none'"> <div class="invalid-feedback" :class="categoryAlert ? 'd-block' : 'd-none'">카테고리를 선택해주세요.</div>
카테고리를 선택해주세요.
</div>
</div> </div>
<!-- 비밀번호 필드 (익명게시판 선택 활성화) --> <!-- 비밀번호 필드 (익명게시판 선택 활성화) -->
@ -52,6 +46,7 @@
title="비밀번호" title="비밀번호"
name="pw" name="pw"
type="password" type="password"
autocomplete="new-password"
:is-essential="true" :is-essential="true"
:is-alert="passwordAlert" :is-alert="passwordAlert"
v-model="password" v-model="password"
@ -74,7 +69,11 @@
<p v-if="fileError" class="text-danger">{{ fileError }}</p> <p v-if="fileError" class="text-danger">{{ fileError }}</p>
<ul class="list-group mt-2" v-if="attachFiles.length"> <ul class="list-group mt-2" v-if="attachFiles.length">
<li v-for="(file, index) in attachFiles" :key="index" class="list-group-item d-flex justify-content-between align-items-center"> <li
v-for="(file, index) in attachFiles"
:key="index"
class="list-group-item d-flex justify-content-between align-items-center"
>
{{ file.name }} {{ file.name }}
<button class="close-btn" @click="removeFile(index)"></button> <button class="close-btn" @click="removeFile(index)"></button>
</li> </li>
@ -82,15 +81,11 @@
<!-- 내용 입력 (에디터) --> <!-- 내용 입력 (에디터) -->
<div class="mb-4"> <div class="mb-4">
<label class="col-md-2 col-form-label"> <label class="col-md-2 col-form-label"> 내용 <span class="text-danger">*</span> </label>
내용 <span class="text-danger">*</span>
</label>
<div class="col-md-12"> <div class="col-md-12">
<QEditor @update:data="content = $event" /> <QEditor @update:data="content = $event" />
</div> </div>
<div class="invalid-feedback mt-1" :class="contentAlert ? 'd-block' : 'd-none'"> <div class="invalid-feedback mt-1" :class="contentAlert ? 'd-block' : 'd-none'">내용을 입력해주세요.</div>
내용을 입력해주세요.
</div>
</div> </div>
<div class="mb-4 d-flex justify-content-end"> <div class="mb-4 d-flex justify-content-end">
@ -104,162 +99,162 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, getCurrentInstance, watch, computed } from 'vue'; import { ref, onMounted, getCurrentInstance, watch, computed } from 'vue';
import QEditor from '@c/editor/QEditor.vue'; import QEditor from '@c/editor/QEditor.vue';
import FormInput from '@c/input/FormInput.vue'; import FormInput from '@c/input/FormInput.vue';
import FormFile from '@c/input/FormFile.vue'; import FormFile from '@c/input/FormFile.vue';
import SaveButton from '@c/button/SaveBtn.vue'; import SaveButton from '@c/button/SaveBtn.vue';
import BackButton from '@c/button/BackBtn.vue'; import BackButton from '@c/button/BackBtn.vue';
import { useToastStore } from '@s/toastStore'; import { useToastStore } from '@s/toastStore';
import router from '@/router'; import router from '@/router';
import axios from '@api'; import axios from '@api';
const toastStore = useToastStore(); const toastStore = useToastStore();
const categoryList = ref([]); const categoryList = ref([]);
const title = ref(''); const title = ref('');
const password = ref(''); const password = ref('');
const categoryValue = ref(null); const categoryValue = ref(null);
const content = ref({ ops: [] }); const content = ref({ ops: [] });
const isFileValid = ref(true); const isFileValid = ref(true);
const titleAlert = ref(false); const titleAlert = ref(false);
const passwordAlert = ref(false); const passwordAlert = ref(false);
const contentAlert = ref(false); const contentAlert = ref(false);
const categoryAlert = ref(false); const categoryAlert = ref(false);
const attachFilesAlert = ref(false); const attachFilesAlert = ref(false);
const attachFiles = ref([]); const attachFiles = ref([]);
const maxFiles = 5; const maxFiles = 5;
const maxSize = 10 * 1024 * 1024; const maxSize = 10 * 1024 * 1024;
const fileError = ref(''); const fileError = ref('');
const fetchCategories = async () => { const fetchCategories = async () => {
try { try {
const response = await axios.get('board/categories'); const response = await axios.get('board/categories');
categoryList.value = response.data.data; categoryList.value = response.data.data;
const freeCategory = categoryList.value.find(category => category.CMNCODNAM === '자유'); const freeCategory = categoryList.value.find(category => category.CMNCODNAM === '자유');
if (freeCategory) { if (freeCategory) {
categoryValue.value = freeCategory.CMNCODVAL; categoryValue.value = freeCategory.CMNCODVAL;
}
} catch (error) {
console.error('카테고리 불러오기 오류:', error);
} }
} catch (error) { };
console.error('카테고리 불러오기 오류:', error);
}
};
onMounted(() => { onMounted(() => {
fetchCategories(); fetchCategories();
}); });
const fileCount = computed(() => attachFiles.value.length); const fileCount = computed(() => attachFiles.value.length);
const handleFileUpload = (files) => { const handleFileUpload = files => {
const validFiles = files.filter(file => file.size <= maxSize); const validFiles = files.filter(file => file.size <= maxSize);
if (files.some(file => file.size > maxSize)) { if (files.some(file => file.size > maxSize)) {
fileError.value = '파일 크기가 10MB를 초과할 수 없습니다.'; fileError.value = '파일 크기가 10MB를 초과할 수 없습니다.';
return; return;
} }
if (attachFiles.value.length + validFiles.length > maxFiles) { if (attachFiles.value.length + validFiles.length > maxFiles) {
fileError.value = `최대 ${maxFiles}개의 파일만 업로드할 수 있습니다.`; fileError.value = `최대 ${maxFiles}개의 파일만 업로드할 수 있습니다.`;
return; return;
} }
fileError.value = '';
attachFiles.value = [...attachFiles.value, ...validFiles].slice(0, maxFiles);
};
const removeFile = (index) => {
attachFiles.value.splice(index, 1);
if (attachFiles.value.length <= maxFiles) {
fileError.value = ''; fileError.value = '';
} attachFiles.value = [...attachFiles.value, ...validFiles].slice(0, maxFiles);
}; };
watch(attachFiles, () => { const removeFile = index => {
isFileValid.value = attachFiles.value.length <= maxFiles; attachFiles.value.splice(index, 1);
}); if (attachFiles.value.length <= maxFiles) {
fileError.value = '';
}
};
const validateTitle = () => { watch(attachFiles, () => {
titleAlert.value = title.value.trim().length === 0; isFileValid.value = attachFiles.value.length <= maxFiles;
}; });
const validatePassword = () => { const validateTitle = () => {
if (categoryValue.value === 300102) { titleAlert.value = title.value.trim().length === 0;
password.value = password.value.replace(/\s/g, ''); // };
passwordAlert.value = password.value.length === 0;
} else {
passwordAlert.value = false;
}
};
const validateContent = () => { const validatePassword = () => {
if (!content.value?.ops?.length) { if (categoryValue.value === 300102) {
contentAlert.value = true; password.value = password.value.replace(/\s/g, ''); //
return; passwordAlert.value = password.value.length === 0;
} } else {
passwordAlert.value = false;
}
};
// const validateContent = () => {
const hasImage = content.value.ops.some(op => op.insert && typeof op.insert === 'object' && op.insert.image); if (!content.value?.ops?.length) {
// contentAlert.value = true;
const hasText = content.value.ops.some(op => typeof op.insert === 'string' && op.insert.trim().length > 0); return;
//
contentAlert.value = !(hasText || hasImage);
};
/** 글쓰기 */
const write = async () => {
validateTitle();
validatePassword();
validateContent();
categoryAlert.value = categoryValue.value == null;
if (titleAlert.value || passwordAlert.value || contentAlert.value || categoryAlert.value || !isFileValid.value) {
return;
}
try {
const boardData = {
LOCBRDTTL: title.value,
LOCBRDCON: JSON.stringify(content.value), // Delta JSON
LOCBRDPWD: categoryValue.value === 300102 ? password.value : null,
LOCBRDTYP: categoryValue.value
};
const { data: boardResponse } = await axios.post('board', boardData);
const boardId = boardResponse.data;
// ( )
if (attachFiles.value && attachFiles.value.length > 0) {
await Promise.all(attachFiles.value.map(async (file) => {
console.log(file);
const formData = new FormData();
const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
formData.append('CMNBRDSEQ', boardId);
formData.append('CMNFLEORG', fileNameWithoutExt);
formData.append('CMNFLEEXT', file.name.split('.').pop());
formData.append('CMNFLESIZ', file.size);
formData.append('file', file); // 📌
await axios.post(`board/${boardId}/attachments`, formData,
{ isFormData : true }
);
}));
} }
toastStore.onToast('게시물이 작성되었습니다.', 's'); //
goList(); const hasImage = content.value.ops.some(op => op.insert && typeof op.insert === 'object' && op.insert.image);
} catch (error) { //
console.error(error); const hasText = content.value.ops.some(op => typeof op.insert === 'string' && op.insert.trim().length > 0);
toastStore.onToast('게시물 작성 중 오류가 발생했습니다.', 'e');
}
};
/** 목록으로 이동 */ //
const goList = () => { contentAlert.value = !(hasText || hasImage);
router.push('/board'); };
};
/** `content` 변경 감지하여 자동 유효성 검사 실행 */ /** 글쓰기 */
watch(content, () => { const write = async () => {
validateContent(); validateTitle();
}); validatePassword();
validateContent();
categoryAlert.value = categoryValue.value == null;
if (titleAlert.value || passwordAlert.value || contentAlert.value || categoryAlert.value || !isFileValid.value) {
return;
}
try {
const boardData = {
LOCBRDTTL: title.value,
LOCBRDCON: JSON.stringify(content.value), // Delta JSON
LOCBRDPWD: categoryValue.value === 300102 ? password.value : null,
LOCBRDTYP: categoryValue.value,
};
const { data: boardResponse } = await axios.post('board', boardData);
const boardId = boardResponse.data;
// ( )
if (attachFiles.value && attachFiles.value.length > 0) {
await Promise.all(
attachFiles.value.map(async file => {
console.log(file);
const formData = new FormData();
const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
formData.append('CMNBRDSEQ', boardId);
formData.append('CMNFLEORG', fileNameWithoutExt);
formData.append('CMNFLEEXT', file.name.split('.').pop());
formData.append('CMNFLESIZ', file.size);
formData.append('file', file); // 📌
await axios.post(`board/${boardId}/attachments`, formData, { isFormData: true });
}),
);
}
toastStore.onToast('게시물이 작성되었습니다.', 's');
goList();
} catch (error) {
console.error(error);
toastStore.onToast('게시물 작성 중 오류가 발생했습니다.', 'e');
}
};
/** 목록으로 이동 */
const goList = () => {
router.push('/board');
};
/** `content` 변경 감지하여 자동 유효성 검사 실행 */
watch(content, () => {
validateContent();
});
</script> </script>

View File

@ -142,28 +142,29 @@ const calendarOptions = reactive({
datesSet: handleMonthChange, datesSet: handleMonthChange,
events: calendarEvents, events: calendarEvents,
}); });
// //
function handleMonthChange(viewInfo) { function handleMonthChange(viewInfo) {
const currentDate = viewInfo.view.currentStart; const currentDate = viewInfo.view.currentStart;
const year = currentDate.getFullYear(); const year = currentDate.getFullYear();
const month = String(currentDate.getMonth() + 1).padStart(2, "0"); const month = String(currentDate.getMonth() + 1).padStart(2, "0");
loadCalendarData(year, month); loadCalendarData(year, month);
} }
// //
function handleDateClick(info) { function handleDateClick(info) {
const clickedDateStr = info.dateStr; const clickedDateStr = info.dateStr;
const clickedDate = info.date; const clickedDate = info.date;
const todayStr = new Date().toISOString().split("T")[0]; const todayStr = new Date().toISOString().split("T")[0];
if ( if (
clickedDate.getDay() === 0 || clickedDate.getDay() === 0 ||
clickedDate.getDay() === 6 || clickedDate.getDay() === 6 ||
holidayDates.value.has(clickedDateStr) || holidayDates.value.has(clickedDateStr) ||
clickedDateStr < todayStr clickedDateStr < todayStr
){ ) {
return; return;
} }
const isMyVacation = myVacations.value.some(vac => { const isMyVacation = myVacations.value.some(vac => {
const vacDate = vac.date ? String(vac.date).substring(0, 10) : ""; const vacDate = vac.date ? vac.date.substring(0, 10) : "";
return vacDate === clickedDateStr && !vac.receiverId; return vacDate === clickedDateStr && !vac.receiverId;
}); });
if (isMyVacation) { if (isMyVacation) {
@ -186,7 +187,6 @@ function handleDateClick(info) {
selectedDates.value.set(clickedDateStr, type); selectedDates.value.set(clickedDateStr, type);
halfDayType.value = null; halfDayType.value = null;
updateCalendarEvents(); updateCalendarEvents();
//
if (halfDayButtonsRef.value) { if (halfDayButtonsRef.value) {
halfDayButtonsRef.value.resetHalfDay(); halfDayButtonsRef.value.resetHalfDay();
} }
@ -266,15 +266,24 @@ const handleProfileClick = async (user) => {
const fetchUserList = async () => { const fetchUserList = async () => {
try { try {
await userListStore.fetchUserList(); await userListStore.fetchUserList();
userList.value = userListStore.userList;
// "ROLE_ADMIN"
const filteredUsers = userListStore.userList.filter(user => user.MEMBERROL !== "ROLE_ADMIN");
// userList
userList.value = [...filteredUsers];
if (!userList.value.length) { if (!userList.value.length) {
console.warn("📌 사용자 목록이 비어 있음!"); console.warn("📌 사용자 목록이 비어 있음!");
return; return;
} }
//
userColors.value = {}; userColors.value = {};
userList.value.forEach((user) => { userList.value.forEach((user) => {
userColors.value[user.MEMBERSEQ] = user.usercolor; userColors.value[user.MEMBERSEQ] = user.usercolor;
}); });
} catch (error) { } catch (error) {
console.error("📌 사용자 목록 불러오기 오류:", error); console.error("📌 사용자 목록 불러오기 오류:", error);
} }
@ -310,47 +319,57 @@ const filteredReceivedVacations = computed(() => {
}); });
}); });
/* 휴가 저장 */
/* 휴가 변경사항 저장 */ //
async function saveVacationChanges() { async function saveVacationChanges() {
if (!hasChanges.value) return; if (!hasChanges.value) return;
const selectedDatesArray = Array.from(selectedDates.value); const selectedDatesArray = Array.from(selectedDates.value);
const vacationsToAdd = selectedDatesArray const vacationChangesByYear = selectedDatesArray.reduce((acc, [date, type]) => {
.filter(([date, type]) => type !== "delete") const year = date.split("-")[0]; // YYYY-MM-DD YYYY
.filter(([date, type]) => if (!acc[year]) acc[year] = { add: [], delete: [] };
!myVacations.value.some(vac => vac.date && vac.date.startsWith(date)) || if (type !== "delete") {
myVacations.value.some(vac => vac.date && vac.date.startsWith(date) && vac.receiverId) acc[year].add.push({ date, type });
)
.map(([date, type]) => ({ date, type }));
const vacationsToDelete = myVacations.value
.filter(vac => {
if (!vac.date) return false;
const date = vac.date.split("T")[0];
return selectedDates.value.get(date) === "delete" && !vac.receiverId;
})
.map(vac => {
const id = vac.id;
return typeof id === "number" ? Number(id) : id;
});
try {
const response = await axios.post("vacation/batchUpdate", {
add: vacationsToAdd,
delete: vacationsToDelete
});
if (response.data && response.data.status === "OK") {
toastStore.onToast('휴가 변경 사항이 저장되었습니다.', 's');
await fetchVacationHistory(lastRemainingYear.value);
await fetchRemainingVacation();
if (isModalOpen.value) {
await fetchVacationHistory(lastRemainingYear.value);
}
const currentDate = fullCalendarRef.value.getApi().getDate();
await loadCalendarData(currentDate.getFullYear(), currentDate.getMonth() + 1);
selectedDates.value.clear();
updateCalendarEvents();
} else { } else {
toastStore.onToast('휴가 저장 중 오류가 발생했습니다.', 'e'); acc[year].delete.push(date);
} }
return acc;
}, {});
try {
for (const year of Object.keys(vacationChangesByYear)) {
const vacationsToAdd = vacationChangesByYear[year].add;
// id
const vacationsToDeleteForYear = myVacations.value
.filter(vac => {
if (!vac.date) return false;
const vacDate = vac.date.split("T")[0];
return vacationChangesByYear[year].delete.includes(vacDate);
});
const vacationIdsToDelete = vacationsToDeleteForYear.map(vac => vac.id);
if (vacationsToAdd.length > 0 || vacationIdsToDelete.length > 0) {
const response = await axios.post("vacation/batchUpdate", {
add: vacationsToAdd,
delete: vacationIdsToDelete,
});
if (response.data && response.data.status === "OK") {
toastStore.onToast(`휴가 변경 사항이 저장되었습니다.`, 's');
// : myVacations ID
myVacations.value = myVacations.value.filter(vac => !vacationIdsToDelete.includes(vac.id));
//
const updatedVacations = await fetchVacationHistory(year);
if (updatedVacations) {
myVacations.value = updatedVacations; //
}
} else {
toastStore.onToast(`휴가 변경 중 오류가 발생했습니다.`, 'e');
}
}
}
await fetchRemainingVacation();
selectedDates.value.clear();
updateCalendarEvents();
//
const currentDate = fullCalendarRef.value.getApi().getDate();
await loadCalendarData(currentDate.getFullYear(), currentDate.getMonth() + 1);
} catch (error) { } catch (error) {
console.error("🚨 휴가 변경 저장 실패:", error); console.error("🚨 휴가 변경 저장 실패:", error);
toastStore.onToast('휴가 저장 요청에 실패했습니다.', 'e'); toastStore.onToast('휴가 저장 요청에 실패했습니다.', 'e');
@ -363,15 +382,17 @@ async function fetchVacationHistory(year) {
try { try {
const response = await axios.get(`vacation/history?year=${year}`); const response = await axios.get(`vacation/history?year=${year}`);
if (response.status === 200 && response.data) { if (response.status === 200 && response.data) {
myVacations.value = response.data.data.usedVacations || []; const newVacations = response.data.data.usedVacations || [];
receivedVacations.value = response.data.data.receivedVacations || [];
} else { const uniqueVacations = Array.from(
console.warn("❌ 연차 내역을 불러오지 못했습니다."); new Map([...myVacations.value, ...newVacations].map(v => [`${v.date}-${v.type}`, v]))
myVacations.value = []; .values()
receivedVacations.value = []; );
myVacations.value = uniqueVacations;
} }
} catch (error) { } catch (error) {
console.error("🚨 연차 데이터 불러오기 실패:", error); console.error(`🚨 ${year}년 휴가 데이터 불러오기 실패:`, error);
} }
} }
// //
@ -462,16 +483,20 @@ function toggleHalfDay(type) {
/* 페이지 이동 시 변경 사항 확인 */ /* 페이지 이동 시 변경 사항 확인 */
router.beforeEach((to, from, next) => { router.beforeEach((to, from, next) => {
if (hasChanges.value) { if (hasChanges.value) {
console.log('휴가!!!!!');
const answer = window.confirm("저장하지 않은 변경 사항이 있습니다. 이동하시겠습니까?"); const answer = window.confirm("저장하지 않은 변경 사항이 있습니다. 이동하시겠습니까?");
if (!answer) { if (!answer) {
return next(false); return next(false);
} }
} }
selectedDates.value.clear();
next(); next();
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener("beforeunload", preventUnsavedChanges); window.removeEventListener("beforeunload", preventUnsavedChanges);
}); });
/* 새로고침 또는 페이지 종료 시 알림 */
function preventUnsavedChanges(event) { function preventUnsavedChanges(event) {
if (hasChanges.value) { if (hasChanges.value) {
event.preventDefault(); event.preventDefault();
@ -480,8 +505,10 @@ function preventUnsavedChanges(event) {
} }
/* watch */ /* watch */
watch(lastRemainingYear, async (newYear, oldYear) => { watch(() => lastRemainingYear.value, async (newYear, oldYear) => {
await fetchVacationHistory(newYear); if (newYear !== oldYear) {
await fetchVacationHistory(newYear);
}
}); });
// `selectedDates` // `selectedDates`
watch( watch(
@ -533,6 +560,9 @@ onMounted(async () => {
altFormat: "F Y" altFormat: "F Y"
}) })
], ],
onOpen: function() {
document.querySelector('.flatpickr-input').style.visibility = 'hidden';
},
onChange: function(selectedDatesArr, dateStr) { onChange: function(selectedDatesArr, dateStr) {
// //
fullCalendarRef.value.getApi().gotoDate(dateStr + "-01"); fullCalendarRef.value.getApi().gotoDate(dateStr + "-01");
@ -551,17 +581,17 @@ onMounted(async () => {
if (titleEl) { if (titleEl) {
titleEl.style.cursor = 'pointer'; titleEl.style.cursor = 'pointer';
titleEl.addEventListener('click', () => { titleEl.addEventListener('click', () => {
const dpEl = calendarDatepicker.value; const dpEl = calendarDatepicker.value;
dpEl.style.display = 'block'; dpEl.style.display = 'block';
dpEl.style.position = 'fixed'; dpEl.style.position = 'fixed';
dpEl.style.top = '25%'; dpEl.style.top = '25%';
dpEl.style.left = '50%'; dpEl.style.left = '50%';
dpEl.style.transform = 'translate(-50%, -50%)'; dpEl.style.transform = 'translate(-50%, -50%)';
dpEl.style.zIndex = '9999'; dpEl.style.zIndex = '9999';
dpEl.style.border = 'none'; dpEl.style.border = 'none';
dpEl.style.outline = 'none'; dpEl.style.outline = 'none';
dpEl.style.backgroundColor = 'transparent'; dpEl.style.backgroundColor = 'transparent';
fpInstance.open(); fpInstance.open();
}); });
} }
}); });

View File

@ -77,20 +77,21 @@ const changeCheck = () =>{
getvoteList(); getvoteList();
} }
// //
const getvoteList = async () => { const getvoteList = () => {
const response = await $api.get('vote/getVoteList',{ $api.get('vote/getVoteList',{
params: //
{ params:
page: currentPage.value {
,voteset:voteset.value page: currentPage.value
,myVote:ischeked.value ? '1':'0' ,voteset:voteset.value
} ,myVote:ischeked.value ? '1':'0'
}); }
if (response.data.status === "OK") { }).then(res => {
PageData.value = response.data.data; PageData.value = res.data.data;
voteListCardData.value = response.data.data.list; voteListCardData.value = res.data.data.list;
} })
}; };
const selectHandler = () =>{ const selectHandler = () =>{
voteset.value = category.value; voteset.value = category.value;
getvoteList(); getvoteList();
@ -148,14 +149,17 @@ const voteDelete =(id) =>{
} }
// 1 // 1
const randomList = (data,id) =>{ const randomList = (data,id) =>{
isLoading.value = false;
$api.post('vote/randomList',{ $api.post('vote/randomList',{
randomList :data randomList :data
,voteid:id ,voteid:id
}).then((res)=>{ }).then((res)=>{
if(res.data.status === 'OK'){ if(res.data.status === 'OK'){
toastStore.onToast('랜덤뽑기 진행되었습니다.', 's'); toastStore.onToast('랜덤뽑기 진행되었습니다.', 's');
getvoteList(); setTimeout(() => {
} getvoteList();
}, 2000); // 3000ms = 3
}
}) })
} }
// //

View File

@ -1,45 +1,28 @@
<template> <template>
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<div class="card p-5"> <div >
<!-- 타이틀, 검색 --> <!-- 타이틀, 검색 -->
<div class="row"> <SearchBar @update:data="search"/>
<div class="col-12 col-md-6">
<h5 class="mb-0 title">용어집</h5>
</div>
<div class="col-12 col-md-6">
<SearchBar @update:data="search"/>
</div>
</div>
<!-- 단어 갯수, 작성하기 --> <!-- 단어 갯수, 작성하기 -->
<div class="mt-4"> <WriteButton ref="writeButton" @click="writeStore.toggleItem(999999)" :isToggleEnabled="true"/>
<WriteButton ref="writeButton" @click="writeStore.toggleItem(999999)" :isToggleEnabled="true"/>
</div>
<!-- --> <!-- -->
<div> <DictAlphabetFilter @update:data="handleSelectedAlphabetChange" :indexCategory="indexCategory" :selectedAl="selectedAlphabet" />
<DictAlphabetFilter @update:data="handleSelectedAlphabetChange" />
</div>
<!-- 카테고리 --> <!-- 카테고리 -->
<div v-if="cateList.length" class="mt-5"> <div v-if="cateList.length">
<CategoryBtn :lists="cateList" @update:data="handleSelectedCategoryChange"/> <CategoryBtn :lists="cateList" @update:data="handleSelectedCategoryChange" :showAll="true"/>
</div> </div>
<!-- 작성 --> <!-- 작성 -->
<div v-if="writeStore.isItemActive(999999)" class="mt-5"> <div v-if="writeStore.isItemActive(999999)" class="mt-5 card p-5">
<DictWrite @close="writeStore.closeAll()" :dataList="cateList" @addWord="addWord"/> <DictWrite @close="writeStore.closeAll()" :dataList="cateList" @addWord="addWord"/>
</div> </div>
</div> </div>
<!-- 용어 리스트 --> <!-- 용어 리스트 -->
<div class="mt-10"> <div >
<!-- 로딩 중일 --> <!-- 로딩 중일 -->
<div v-if="loading">로딩 중...</div> <LoadingSpinner v-if="loading"/>
<!-- 에러 메시지 --> <!-- 에러 메시지 -->
<div v-if="error" class="error">{{ error }}</div> <div v-if="error" class="error">{{ error }}</div>
<!-- 단어 목록 --> <!-- 단어 목록 -->
<ul v-if="total > 0" class="px-0 list-unstyled"> <ul v-if="total > 0" class="px-0 list-unstyled">
<DictCard <DictCard
@ -47,14 +30,12 @@
:key="item.WRDDICSEQ" :key="item.WRDDICSEQ"
:item="item" :item="item"
v-model:cateList="cateList" v-model:cateList="cateList"
@refreshWordList="getwordList" @refreshWordList="refreshWordList"
@updateChecked="updateCheckedItems" @updateChecked="updateCheckedItems"
/> />
</ul> </ul>
<!-- 데이터가 없을 --> <!-- 데이터가 없을 -->
<div v-else-if="!loading && !error" class="card p-5 text-center">용어집의 용어가 없습니다.</div> <div v-if="total == 0" class="text-center mt-5">용어를 선택 / 작성해 주세요 </div>
</div> </div>
</div> </div>
@ -77,6 +58,7 @@
import commonApi from '@/common/commonApi'; import commonApi from '@/common/commonApi';
import { useToastStore } from '@s/toastStore'; import { useToastStore } from '@s/toastStore';
import { useWriteVisibleStore } from '@s/writeVisible'; import { useWriteVisibleStore } from '@s/writeVisible';
import LoadingSpinner from "@v/LoadingPage.vue";
// //
const writeStore = useWriteVisibleStore(); const writeStore = useWriteVisibleStore();
@ -116,11 +98,19 @@
// //
const searchText = ref(''); const searchText = ref('');
//
const indexCategory = ref([]);
// //
onMounted(() => { onMounted(() => {
getwordList(); getIndex();
writeStore.closeAll();
}); });
const refreshWordList = () => {
getwordList(searchText.value, selectedAlphabet.value, selectedCategory.value);
};
// //
const getwordList = (searchKeyword='', indexKeyword='', category='') => { const getwordList = (searchKeyword='', indexKeyword='', category='') => {
axios.get('worddict/getWordList',{ axios.get('worddict/getWordList',{
@ -144,6 +134,14 @@
loading.value = false; loading.value = false;
}); });
}; };
//
const getIndex = () => {
axios.get('worddict/getIndexCategory').then(res=>{
if(res.data.status ="OK"){
indexCategory.value = res.data.data;
}
})
}
// //
const search = (e) => { const search = (e) => {
@ -154,41 +152,49 @@
// //
const handleSelectedAlphabetChange = (newAlphabet) => { const handleSelectedAlphabetChange = (newAlphabet) => {
selectedAlphabet.value = newAlphabet; selectedAlphabet.value = newAlphabet;
getwordList(searchText.value, selectedAlphabet.value, selectedCategory.value); if (newAlphabet !== null) {
getwordList(searchText.value, selectedAlphabet.value, selectedCategory.value);
} else {
wordList.value = [];
total.value = 0;
}
}; };
// //
const handleSelectedCategoryChange = (category) => { const handleSelectedCategoryChange = (category) => {
selectedCategory.value = category; selectedCategory.value = category;
getwordList(searchText.value, selectedAlphabet.value, selectedCategory.value); if (category !== null ) {
getwordList(searchText.value, selectedAlphabet.value, selectedCategory.value);
if(category == 'all'){
getwordList(searchText.value, selectedAlphabet.value, '');
}
} else {
wordList.value = [];
total.value = 0;
}
} }
// //
const addWord = (wordData, data) => { const addWord = (wordData, data) => {
let category = null; let category = null;
let newCodName = '';
// //
const existingCategory = cateList.value.find(item => item.label === data.trim()); if(typeof(data) == 'number'){
category = data;
if (existingCategory) { newCodName = '';
// }else{
category = existingCategory.label == '' ? wordData.category : existingCategory.value;
} else {
//
const lastCategory = cateList.value[cateList.value.length - 1]; const lastCategory = cateList.value[cateList.value.length - 1];
category = lastCategory ? lastCategory.value + 1 : 600101; category = lastCategory ? lastCategory.value + 1 : 600101;
newCodName = data;
} }
sendWordRequest(category, wordData, data, !existingCategory); sendWordRequest(category, wordData, newCodName);
}; };
const sendWordRequest = (category, wordData, data) => {
const sendWordRequest = (category, wordData, data, isNewCategory) => {
const payload = { const payload = {
WRDDICCAT: category, WRDDICCAT: category,
WRDDICTTL: wordData.title, WRDDICTTL: wordData.title,
WRDDICCON: $common.deltaAsJson(wordData.content), WRDDICCON: $common.deltaAsJson(wordData.content),
}; };
if (isNewCategory) {
payload.CMNCODNAM = data; payload.CMNCODNAM = data;
axios.post('worddict/insertWord', payload).then(res => { axios.post('worddict/insertWord', payload).then(res => {
if (res.data.status === 'OK') { if (res.data.status === 'OK') {
@ -198,22 +204,14 @@
writeButton.value.resetButton(); writeButton.value.resetButton();
} }
getwordList(); getwordList();
const newCategory = { label: data, value: category }; getIndex();
cateList.value = [newCategory, ...cateList.value]; if(res.data.data == '2'){
} const newCategory = { label: data, value: category };
}); cateList.value = [...cateList.value,newCategory];
} else {
axios.post('worddict/insertWord', payload).then(res => {
if (res.data.status === 'OK') {
toastStore.onToast('용어가 등록 되었습니다.', 's');
writeStore.closeAll();
if (writeButton.value) {
writeButton.value.resetButton();
} }
getwordList(); selectedAlphabet.value = '';
} }
}); });
}
}; };