Merge branch 'main' into login

This commit is contained in:
yoon 2025-03-06 13:16:47 +09:00
commit 17fb20ae1e
10 changed files with 296 additions and 266 deletions

View File

@ -78,14 +78,14 @@ opacity: 0.6; /* 흐려 보이게 */
border: none !important; border: none !important;
border-radius: 4px; border-radius: 4px;
} }
/* 오전 반차 (왼쪽 절반) */ /* 오전 반차 활성화 영역 (왼쪽 절반) */
.selected-event.half-day-am { .selected-event.half-day-am {
width: 50% !important; width: 50% !important;
left: 0 !important; left: 0 !important;
border-top-left-radius: 4px; border-top-left-radius: 4px;
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
} }
/* 오후 반차 (오른쪽 절반) */ /* 오후 반차 활성화 영역 (오른쪽 절반) */
.selected-event.half-day-pm { .selected-event.half-day-pm {
width: 50% !important; width: 50% !important;
margin-left: auto !important; margin-left: auto !important;
@ -105,6 +105,30 @@ opacity: 0.6; /* 흐려 보이게 */
font-size: 18px; font-size: 18px;
cursor: pointer; cursor: pointer;
} }
.close-btn:hover {
color: #525252;
}
/* 모달 배경 투명하게 */
.vac-modal-dialog {
background: none !important;
box-shadow: none !important;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* 모달 본문 스타일 */
.vac-modal-content {
background: #fff;
border-radius: 8px;
box-shadow: 0px -4px 10px rgba(0, 0, 0, 0.1); /* 위쪽 그림자만 적용 */
padding: 20px;
max-width: 500px;
width: 100%;
position: relative;
}
/* 리스트 아이템 */ /* 리스트 아이템 */
.vacation-item { .vacation-item {
display: flex; display: flex;

View File

@ -51,65 +51,78 @@
</template> </template>
<script setup> <script setup>
import { ref, defineEmits, defineProps, computed, 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: '',
}, },
}); });
const $common = inject('common');
const comment = ref('');
const password = ref('');
const isCheck = ref(props.unknown);
const textAlert = ref('');
const passwordAlert2 = ref('');
const emit = defineEmits(['submitComment']); const $common = inject('common');
const LOCBRDTYP = isCheck.value ? '300102' : null; const comment = ref('');
function handleCommentSubmit() { const password = ref('');
if (!$common.isNotEmpty(comment.value)) { const isCheck = ref(false);
textAlert.value = '댓글을 입력하세요'; const textAlert = ref('');
return false; const passwordAlert2 = ref('');
} else {
textAlert.value = '';
}
if (isCheck.value && !$common.isNotEmpty(password.value)) {
passwordAlert2.value = '비밀번호를 입력하세요';
return false;
} else {
passwordAlert2.value = '';
}
emit('submitComment', { const emit = defineEmits(['submitComment']);
comment: comment.value,
password: isCheck.value ? password.value : '', const handleCommentSubmit = () => {
isCheck: isCheck.value, if (!$common.isNotEmpty(comment.value)) {
LOCBRDTYP, // '300102' textAlert.value = '댓글을 입력하세요';
}); return false;
} else {
textAlert.value = '';
} }
watch( if (isCheck.value && !$common.isNotEmpty(password.value)) {
() => props.passwordAlert, passwordAlert2.value = '비밀번호를 입력하세요';
() => { return false;
if (!props.passwordAlert) { } else {
comment.value = ''; passwordAlert2.value = '';
password.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> </script>

View File

@ -2,9 +2,9 @@
<div class="d-flex align-items-center flex-wrap"> <div class="d-flex align-items-center flex-wrap">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div v-if="!unknown" class="avatar me-2"> <div v-if="!unknown" class="avatar me-2">
<img src="/img/avatars/2.png" alt="Avatar" class="rounded-circle" /> <img :src="getProfileImage(profilePath)" alt="Avatar" class="rounded-circle" />
</div> </div>
<div class="me-2"> <div class="me-2">
<h6 class="mb-0">{{ profileName }}</h6> <h6 class="mb-0">{{ profileName }}</h6>
<div class="profile-detail"> <div class="profile-detail">
@ -23,8 +23,7 @@
<!-- 버튼 영역 --> <!-- 버튼 영역 -->
<div class="ms-auto text-end"> <div class="ms-auto text-end">
<!-- 수정, 삭제 버튼 --> <!-- 수정, 삭제 버튼 -->
<!-- <template v-if="isAuthor || showDetail"> --> <template v-if="!isDeletedComment && (unknown || isCommentAuthor || isAuthor)">
<template v-if="unknown || isCommentAuthor || isAuthor">
<EditButton @click.stop="editClick" /> <EditButton @click.stop="editClick" />
<DeleteButton @click.stop="deleteClick" /> <DeleteButton @click.stop="deleteClick" />
</template> </template>
@ -35,18 +34,23 @@
:boardId="boardId" :boardId="boardId"
:comment="comment" :comment="comment"
@updateReaction="handleUpdateReaction" @updateReaction="handleUpdateReaction"
> />
</BoardRecommendBtn>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, defineProps, defineEmits } from 'vue'; import { computed, defineProps, defineEmits } from 'vue';
import DeleteButton from '../button/DeleteBtn.vue'; import DeleteButton from '../button/DeleteBtn.vue';
import EditButton from '../button/EditBtn.vue'; import EditButton from '../button/EditBtn.vue';
import BoardRecommendBtn from '../button/BoardRecommendBtn.vue'; import BoardRecommendBtn from '../button/BoardRecommendBtn.vue';
//
const defaultProfile = "/img/icons/icon.png";
// (Vue )
const baseUrl = "http://localhost:10325/"; // API URL
// Props // Props
const props = defineProps({ const props = defineProps({
comment: { comment: {
@ -65,6 +69,10 @@ const props = defineProps({
type: String, type: String,
default: '', default: '',
}, },
profilePath: {
type: String,
default: '',
},
unknown: { unknown: {
type: Boolean, type: Boolean,
default: true, default: true,
@ -73,13 +81,12 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: true, default: true,
}, },
// :
isAuthor: { isAuthor: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isCommentAuthor: Boolean, // isCommentAuthor: Boolean,
isCommentProfile: Boolean, // isCommentProfile: Boolean,
date: { date: {
type: String, type: String,
required: '', required: '',
@ -100,6 +107,11 @@ const props = defineProps({
const emit = defineEmits(['updateReaction', 'editClick', 'deleteClick']); const emit = defineEmits(['updateReaction', 'editClick', 'deleteClick']);
const isDeletedComment = computed(() => {
return props.comment?.content === '삭제된 댓글입니다' &&
props.comment?.updateAtRaw !== props.comment?.createdAtRaw;
});
// //
const editClick = () => { const editClick = () => {
emit('editClick', { ...props.comment, unknown: props.unknown }); emit('editClick', { ...props.comment, unknown: props.unknown });
@ -110,42 +122,19 @@ const deleteClick = () => {
emit('deleteClick', { ...props.comment, unknown: props.unknown }); emit('deleteClick', { ...props.comment, unknown: props.unknown });
}; };
// /
const handleUpdateReaction = (reactionData) => { const handleUpdateReaction = (reactionData) => {
emit("updateReaction", { emit("updateReaction", {
boardId: props.boardId, boardId: props.boardId,
commentId: props.comment?.commentId, commentId: props.comment?.commentId,
...reactionData, ...reactionData,
}); });
}; };
//
const getProfileImage = (profilePath) => { const getProfileImage = (profilePath) => {
return profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile; return profilePath && profilePath.trim()
}; ? `${baseUrl}upload/img/profile/${profilePath}`
: defaultProfile;
};
</script> </script>
<style scoped>
.profile-detail span ~ span {
margin-left: 5px;
}
.ms-auto button + button {
margin-left: 5px;
}
.btn.author {
height: 30px;
}
@media screen and (max-width: 450px) {
.btn-area {
margin-top: 10px;
width: 100%;
}
.btn.author {
height: 30px;
}
}
</style>

View File

@ -1,26 +1,29 @@
<template> <template>
<div v-if="isOpen" class="modal-dialog" @click.self="closeModal"> <div v-if="isOpen" class="vac-modal-dialog" @click.self="closeModal">
<div class="modal-content p-5"> <div class="vac-modal-content p-5">
<h5 class="modal-title">To. {{ targetUser.MEMBERNAM }} 🎁</h5> <div class="modal-header">
<button class="close-btn" @click="closeModal"></button> <h5 class="modal-title">To. {{ targetUser.MEMBERNAM }} 🎁</h5>
<button class="close-btn" @click="closeModal"></button>
</div>
<div class="modal-body"> <div class="modal-body">
<p>선물할 연차 개수를 선택하세요.</p> <p>선물할 연차 개수를 선택하세요.</p>
<div class="justify-content-center d-sm-flex gap-sm-3 align-items-md-center mt-8"> <div class="justify-content-center d-sm-flex gap-sm-3 align-items-md-center mt-8">
<button @click="decreaseCount" :disabled="grantCount < 2" class="count-btn">-</button> <button @click="decreaseCount" :disabled="grantCount < 2" class="count-btn">-</button>
<span class="text-dark fw-bold fs-4">{{ grantCount }}</span> <span class="text-dark fw-bold fs-4">{{ grantCount }}</span>
<button @click="increaseCount" :disabled="grantCount >= availableQuota" class="count-btn">+</button> <button @click="increaseCount" :disabled="grantCount >= availableQuota" class="count-btn">+</button>
</div> </div>
<div class="custom-button-container">
<button class="custom-button" @click="saveVacationGrant" :disabled="grantCount === 0"> <div class="custom-button-container">
<i class="bx bx-gift"></i> <button class="custom-button" @click="saveVacationGrant" :disabled="grantCount === 0">
</button> <i class="bx bx-gift"></i>
</div> </button>
</div>
</div>
</div> </div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, defineProps, defineEmits, watch, onMounted } from "vue"; import { ref, defineProps, defineEmits, watch, onMounted } from "vue";
@ -121,25 +124,6 @@
</script> </script>
<style scoped> <style scoped>
/* 모달 배경 투명하게 */
.modal-dialog {
background: none !important; /* 배경 제거 */
box-shadow: none !important; /* 음영 제거 */
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* 모달 내용 스타일 */ </style>
.modal-content {
background: #fff; /* 기존 흰색 배경 유지 */
border-radius: 8px;
box-shadow: none !important; /* 내부 음영 제거 */
padding: 20px;
max-width: 500px;
width: 100%;
}
</style>

View File

@ -1,6 +1,6 @@
<template> <template>
<div v-if="isOpen" class="modal-dialog" @click.self="closeModal"> <div v-if="isOpen" class="vac-modal-dialog" @click.self="closeModal">
<div class="modal-content p-5 modal-scroll"> <div class="vac-modal-content p-5 modal-scroll">
<h5 class="modal-title">📅 연차 사용 내역</h5> <h5 class="modal-title">📅 연차 사용 내역</h5>
<button class="close-btn" @click="closeModal"></button> <button class="close-btn" @click="closeModal"></button>
@ -138,25 +138,4 @@ const closeModal = () => {
</script> </script>
<style scoped> <style scoped>
/* 모달 배경 투명하게 */
.modal-dialog {
background: none !important; /* 배경 제거 */
box-shadow: none !important; /* 음영 제거 */
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
/* 모달 내용 스타일 */
.modal-content {
background: #fff; /* 기존 흰색 배경 유지 */
border-radius: 8px;
box-shadow: none !important; /* 내부 음영 제거 */
padding: 20px;
max-width: 500px;
width: 100%;
}
</style> </style>

View File

@ -8,8 +8,8 @@
<div class="navbar-nav-right d-flex align-items-center" id="navbar-collapse"> <div class="navbar-nav-right d-flex align-items-center" id="navbar-collapse">
<ul class="navbar-nav flex-row align-items-center ms-auto"> <ul class="navbar-nav flex-row align-items-center ms-auto">
<button class="btn p-1" @click="switchToLightMode"><i class="bx bxs-sun link-warning"></i></button> <!-- <button class="btn p-1" @click="switchToLightMode"><i class="bx bxs-sun link-warning"></i></button> -->
<button class="btn p-1" @click="switchToDarkMode"><i class="bx bxs-moon"></i></button> <!-- <button class="btn p-1" @click="switchToDarkMode"><i class="bx bxs-moon"></i></button> -->
<i class="bx bx-bell bx-md bx-log-out cursor-pointer p-1" @click="handleLogout"></i> <i class="bx bx-bell bx-md bx-log-out cursor-pointer p-1" @click="handleLogout"></i>

View File

@ -61,7 +61,7 @@
📌 {{ notice.title }} 📌 {{ notice.title }}
<span v-if="notice.commentCount" class="comment-count">[ {{ notice.commentCount }} ]</span> <span v-if="notice.commentCount" class="comment-count">[ {{ notice.commentCount }} ]</span>
<i v-if="notice.img" class="bi bi-image me-1"></i> <i v-if="notice.img" class="bi bi-image me-1"></i>
<i v-if="notice.hasAttachment" class="bi bi-paperclip"></i> <i v-if="Array.isArray(notice.hasAttachment) && notice.hasAttachment.length > 0" class="bi bi-paperclip"></i>
<span v-if="isNewPost(notice.rawDate)" class="badge bg-danger text-white ms-2 fs-tiny">N</span> <span v-if="isNewPost(notice.rawDate)" class="badge bg-danger text-white ms-2 fs-tiny">N</span>
</td> </td>
<td class="text-center">{{ notice.author }}</td> <td class="text-center">{{ notice.author }}</td>
@ -79,7 +79,7 @@
{{ post.title }} {{ post.title }}
<span v-if="post.commentCount" class="comment-count">[ {{ post.commentCount }} ]</span> <span v-if="post.commentCount" class="comment-count">[ {{ post.commentCount }} ]</span>
<i v-if="post.img" class="bi bi-image me-1"></i> <i v-if="post.img" class="bi bi-image me-1"></i>
<i v-if="post.hasAttachment" class="bi bi-paperclip"></i> <i v-if="Array.isArray(post.hasAttachment) && post.hasAttachment.length > 0" class="bi bi-paperclip"></i>
<span v-if="isNewPost(post.rawDate)" class="badge bg-danger text-white ms-2 fs-tiny">N</span> <span v-if="isNewPost(post.rawDate)" class="badge bg-danger text-white ms-2 fs-tiny">N</span>
</td> </td>
<td class="text-center">{{ post.author }}</td> <td class="text-center">{{ post.author }}</td>
@ -206,7 +206,7 @@ const fetchGeneralPosts = async (page = 1) => {
rawDate: post.date, rawDate: post.date,
date: formatDate(post.date), // date: formatDate(post.date), //
views: post.cnt || 0, views: post.cnt || 0,
hasAttachment: post.hasAttachment || false, hasAttachment: post.hasAttachment,
img: post.firstImageUrl || null, img: post.firstImageUrl || null,
commentCount : post.commentCount commentCount : post.commentCount
})); }));
@ -247,7 +247,7 @@ const fetchNoticePosts = async () => {
date: formatDate(post.date), date: formatDate(post.date),
rawDate: post.date, rawDate: post.date,
views: post.cnt || 0, views: post.cnt || 0,
hasAttachment: post.hasAttachment || false, hasAttachment: post.hasAttachment,
img: post.firstImageUrl || null, img: post.firstImageUrl || null,
commentCount : post.commentCount commentCount : post.commentCount
})); }));

View File

@ -17,47 +17,35 @@
:isAuthor="isAuthor" :isAuthor="isAuthor"
@editClick="editClick" @editClick="editClick"
@deleteClick="deleteClick" @deleteClick="deleteClick"
/> />
<!-- 비밀번호 입력창 (익명일 경우) -->
<div v-if="isPassword && unknown" class="mt-3 w-25 ms-auto">
<div class="input-group">
<input
type="password"
class="form-control"
v-model="password"
placeholder="비밀번호 입력"
@input="password = password.replace(/\s/g, '')"
/>
<button class="btn btn-primary" @click="submitPassword">확인</button>
</div>
<span v-if="passwordAlert" class="invalid-feedback d-block text-start">{{ passwordAlert }}</span>
</div>
</div> </div>
</div> </div>
<!-- 게시글 내용 --> <!-- 게시글 내용 -->
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center flex-wrap mb-6 gap-2"> <div class="d-flex justify-content-between align-items-center flex-wrap mb-6 gap-2">
<!-- 제목 섹션 --> <!-- 제목 섹션 -->
<div class="me-1"> <div class="me-1">
<h5 class="mb-4">{{ boardTitle }}</h5> <h5 class="mb-4">{{ boardTitle }}</h5>
</div> </div>
<!-- 첨부파일 섹션 --> <!-- 첨부파일 다운로드 버튼 -->
<div v-if="attachment" class="btn-group"> <div v-if="attachments.length" class="btn-group">
<button type="button" class="btn btn-label-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"> <button type="button" class="btn btn-label-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-download me-2"></i> <i class="fa-solid fa-download me-2"></i>
첨부파일 첨부파일 ({{ attachments.length }})
<!-- (<span class="attachment-num">{{ dropdownItems.length }}</span>) -->
</button> </button>
<!-- <ul class="dropdown-menu"> <ul class="dropdown-menu">
<li v-for="(item, index) in dropdownItems" :key="index"> <li v-for="(attachment, index) in attachments" :key="index">
<a class="dropdown-item" href="javascript:void(0);"> <a
{{ item.label }} class="dropdown-item"
href="#"
@click.prevent="downloadFile(attachment)"
>
{{ attachment.originalName }}.{{ attachment.extension }}
</a> </a>
</li> </li>
</ul> --> </ul>
</div> </div>
</div> </div>
@ -75,15 +63,8 @@
:likeClicked="likeClicked" :likeClicked="likeClicked"
:dislikeClicked="dislikeClicked" :dislikeClicked="dislikeClicked"
@updateReaction="handleUpdateReaction" @updateReaction="handleUpdateReaction"
/> />
</div> </div>
<!-- 첨부파일 목록 -->
<!-- <ul v-if="attachments.length" class="attachments mt-4 list-unstyled">
<li v-for="(attachment, index) in attachments" :key="index" class="mb-2">
<a :href="attachment.url" target="_blank" class="text-decoration-none">{{ attachment.name }}</a>
</li>
</ul> -->
<!-- 댓글 입력 영역 --> <!-- 댓글 입력 영역 -->
<BoardCommentArea <BoardCommentArea
@ -174,6 +155,31 @@ const commentsWithAuthStatus = computed(() => {
return updatedComments; return updatedComments;
}); });
const attachments = ref([]);
// URL
const downloadFile = async (attachment) => {
try {
const response = await axios.get(`board/download`, {
params: { path: attachment.path },
responseType: 'blob'
});
// Blob
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', attachment.originalName + '.' + attachment.extension);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('파일 다운로드 오류:', error);
alert('파일 다운로드 중 오류가 발생했습니다.');
}
};
const password = ref(''); const password = ref('');
const passwordAlert = ref(""); const passwordAlert = ref("");
@ -214,12 +220,9 @@ const fetchBoardDetails = async () => {
const data = response.data.data; const data = response.data.data;
// API
// const boardDetail = data.boardDetail || {};
profileName.value = data.author || '익명'; profileName.value = data.author || '익명';
authorId.value = data.authorId; // id authorId.value = data.authorId;
boardTitle.value = data.title || '제목 없음'; boardTitle.value = data.title || '제목 없음';
boardContent.value = data.content || ''; boardContent.value = data.content || '';
date.value = data.date || ''; date.value = data.date || '';
@ -228,6 +231,7 @@ const fetchBoardDetails = async () => {
dislikes.value = data.dislikeCount || 0; dislikes.value = data.dislikeCount || 0;
attachment.value = data.hasAttachment || null; attachment.value = data.hasAttachment || null;
commentNum.value = data.commentCount || 0; commentNum.value = data.commentCount || 0;
attachments.value = data.attachments || [];
} catch (error) { } catch (error) {
alert('게시물 데이터를 불러오는 중 오류가 발생했습니다.'); alert('게시물 데이터를 불러오는 중 오류가 발생했습니다.');
@ -288,7 +292,6 @@ const fetchComments = async (page = 1) => {
page page
} }
}); });
const commentsList = response.data.data.list.map(comment => ({ const commentsList = response.data.data.list.map(comment => ({
commentId: comment.LOCCMTSEQ, // ID commentId: comment.LOCCMTSEQ, // ID
boardId: comment.LOCBRDSEQ, boardId: comment.LOCBRDSEQ,
@ -303,6 +306,8 @@ const fetchComments = async (page = 1) => {
createdAtRaw: new Date(comment.LOCCMTRDT), // createdAtRaw: new Date(comment.LOCCMTRDT), //
createdAt: formattedDate(comment.LOCCMTRDT), // createdAt: formattedDate(comment.LOCCMTRDT), //
children: [], // children: [], //
updateAtRaw: comment.LOCCMTUDT,
})); }));
commentsList.sort((a, b) => b.createdAtRaw - a.createdAtRaw); commentsList.sort((a, b) => b.createdAtRaw - a.createdAtRaw);
@ -461,10 +466,10 @@ const findCommentById = (commentId, commentsList) => {
// ( ) // ( )
const editComment = (comment) => { const editComment = (comment) => {
password.value = ''; password.value = '';
passwordCommentAlert.value = ''; passwordCommentAlert.value = '';
currentPasswordCommentId.value = null; currentPasswordCommentId.value = null;
const targetComment = findCommentById(comment.commentId, comments.value); const targetComment = findCommentById(comment.commentId, comments.value);
if (!targetComment) { if (!targetComment) {
@ -493,7 +498,7 @@ const editComment = (comment) => {
} else { } else {
// //
closeAllEditTextareas(); closeAllEditTextareas();
// //
targetComment.isEditTextarea = false; targetComment.isEditTextarea = false;
toggleCommentPassword(comment, "edit"); toggleCommentPassword(comment, "edit");
@ -537,7 +542,7 @@ const toggleCommentPassword = (comment, button) => {
passwordCommentAlert.value = ''; passwordCommentAlert.value = '';
} else { } else {
currentPasswordCommentId.value = comment.commentId; // currentPasswordCommentId.value = comment.commentId; //
password.value = ''; password.value = '';
passwordCommentAlert.value = ''; passwordCommentAlert.value = '';
} }

View File

@ -60,14 +60,26 @@
/> />
</div> </div>
<!-- 첨부파일 업로드 -->
<FormFile <FormFile
title="첨부파일" title="첨부파일"
name="files" name="files"
:is-alert="attachFilesAlert" :is-alert="attachFilesAlert"
@update:data="attachFiles = $event" @update:data="handleFileUpload"
@update:isValid="isFileValid = $event" @update:isValid="isFileValid = $event"
/> />
<!-- 실시간 반영된 파일 개수 표시 -->
<p class="text-muted mt-1">첨부파일: {{ fileCount }} / 5</p>
<p v-if="fileError" class="text-danger">{{ fileError }}</p>
<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">
{{ file.name }}
<button class="close-btn" @click="removeFile(index)"></button>
</li>
</ul>
<!-- 내용 입력 (에디터) --> <!-- 내용 입력 (에디터) -->
<div class="mb-4"> <div class="mb-4">
<label class="col-md-2 col-form-label"> <label class="col-md-2 col-form-label">
@ -92,7 +104,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, getCurrentInstance, watch } 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';
@ -107,8 +119,7 @@ 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: [] }); // Delta const content = ref({ ops: [] });
const attachFiles = ref(null);
const isFileValid = ref(true); const isFileValid = ref(true);
const titleAlert = ref(false); const titleAlert = ref(false);
@ -117,8 +128,10 @@ const contentAlert = ref(false);
const categoryAlert = ref(false); const categoryAlert = ref(false);
const attachFilesAlert = ref(false); const attachFilesAlert = ref(false);
const { appContext } = getCurrentInstance(); const attachFiles = ref([]);
const $common = appContext.config.globalProperties.$common; const maxFiles = 5;
const maxSize = 10 * 1024 * 1024;
const fileError = ref('');
const fetchCategories = async () => { const fetchCategories = async () => {
try { try {
@ -137,12 +150,37 @@ onMounted(() => {
fetchCategories(); fetchCategories();
}); });
/** ✅ 제목 유효성 검사 */ const fileCount = computed(() => attachFiles.value.length);
const handleFileUpload = (files) => {
const validFiles = files.filter(file => file.size <= maxSize);
if (files.some(file => file.size > maxSize)) {
fileError.value = '파일 크기가 10MB를 초과할 수 없습니다.';
return;
}
if (attachFiles.value.length + validFiles.length > maxFiles) {
fileError.value = `최대 ${maxFiles}개의 파일만 업로드할 수 있습니다.`;
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 = '';
}
};
watch(attachFiles, () => {
isFileValid.value = attachFiles.value.length <= maxFiles;
});
const validateTitle = () => { const validateTitle = () => {
titleAlert.value = title.value.trim().length === 0; titleAlert.value = title.value.trim().length === 0;
}; };
/** ✅ 비밀번호 유효성 검사 (공백 제거) */
const validatePassword = () => { const validatePassword = () => {
if (categoryValue.value === 300102) { if (categoryValue.value === 300102) {
password.value = password.value.replace(/\s/g, ''); // password.value = password.value.replace(/\s/g, ''); //
@ -152,7 +190,6 @@ const validatePassword = () => {
} }
}; };
/** ✅ 내용 유효성 검사 */
const validateContent = () => { const validateContent = () => {
if (!content.value?.ops?.length) { if (!content.value?.ops?.length) {
contentAlert.value = true; contentAlert.value = true;
@ -168,7 +205,7 @@ const validateContent = () => {
contentAlert.value = !(hasText || hasImage); contentAlert.value = !(hasText || hasImage);
}; };
/** 글쓰기 */ /** 글쓰기 */
const write = async () => { const write = async () => {
validateTitle(); validateTitle();
validatePassword(); validatePassword();
@ -189,10 +226,10 @@ const write = async () => {
const { data: boardResponse } = await axios.post('board', boardData); const { data: boardResponse } = await axios.post('board', boardData);
const boardId = boardResponse.data; const boardId = boardResponse.data;
// ( )
// ( )
if (attachFiles.value && attachFiles.value.length > 0) { if (attachFiles.value && attachFiles.value.length > 0) {
await Promise.all(attachFiles.value.map(async (file) => { await Promise.all(attachFiles.value.map(async (file) => {
console.log(file);
const formData = new FormData(); const formData = new FormData();
const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, ''); const fileNameWithoutExt = file.name.replace(/\.[^/.]+$/, '');
@ -200,12 +237,11 @@ const write = async () => {
formData.append('CMNFLEORG', fileNameWithoutExt); formData.append('CMNFLEORG', fileNameWithoutExt);
formData.append('CMNFLEEXT', file.name.split('.').pop()); formData.append('CMNFLEEXT', file.name.split('.').pop());
formData.append('CMNFLESIZ', file.size); formData.append('CMNFLESIZ', file.size);
formData.append('CMNFLEPAT', 'boardfile'); formData.append('file', file); // 📌
formData.append('file', file);
await axios.post(`board/${boardId}/attachments`, formData, { await axios.post(`board/${boardId}/attachments`, formData,
headers: { 'Content-Type': 'multipart/form-data' }, { isFormData : true }
}); );
})); }));
} }
@ -217,12 +253,12 @@ const write = async () => {
} }
}; };
/** 목록으로 이동 */ /** 목록으로 이동 */
const goList = () => { const goList = () => {
router.push('/board'); router.push('/board');
}; };
/** `content` 변경 감지하여 자동 유효성 검사 실행 */ /** `content` 변경 감지하여 자동 유효성 검사 실행 */
watch(content, () => { watch(content, () => {
validateContent(); validateContent();
}); });

View File

@ -148,7 +148,7 @@ watch(
const calendarDatepicker = ref(null); const calendarDatepicker = ref(null);
let fpInstance = null; let fpInstance = null;
/** 변경사항 여부 확인 */ /** 변경사항 여부 확인 */
const hasChanges = computed(() => { const hasChanges = computed(() => {
return ( return (
selectedDates.value.size > 0 || selectedDates.value.size > 0 ||
@ -157,7 +157,7 @@ const hasChanges = computed(() => {
}); });
/** selectedDates가 변경될 때 버튼 상태 즉시 업데이트 */ /** selectedDates가 변경될 때 버튼 상태 즉시 업데이트 */
watch( watch(
() => Array.from(selectedDates.value.keys()), // keys() Array () => Array.from(selectedDates.value.keys()), // keys() Array
(newKeys) => { (newKeys) => {
@ -204,7 +204,7 @@ 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();
} }
@ -417,42 +417,42 @@ function handleDateClick(info) {
halfDayType.value = halfDayType.value === type ? null : type; halfDayType.value = halfDayType.value === type ? null : type;
} }
async function fetchVacationData(year, month) { //
try { async function fetchVacationData(year, month) {
const response = await axios.get(`vacation/list/${year}/${month}`); try {
if (response.status === 200) { const response = await axios.get(`vacation/list/${year}/${month}`);
const vacationList = response.data; if (response.status === 200) {
if (lastRemainingYear.value !== year) { const vacationList = response.data;
myVacations.value = vacationList.filter(
(vac) => vac.MEMBERSEQ === userStore.user.id //
); const filteredVacations = vacationList.filter(vac =>
lastRemainingYear.value = year; userColors.value[vac.MEMBERSEQ] && userColors.value[vac.MEMBERSEQ] !== "#FFFFFF"
} );
const events = vacationList
.filter((vac) => !vac.LOCVACRMM) const events = filteredVacations.map(vac => {
.map((vac) => { let dateStr = vac.LOCVACUDT ? vac.LOCVACUDT.split("T")[0] : "";
let dateStr = vac.LOCVACUDT ? vac.LOCVACUDT.split("T")[0] : ""; let backgroundColor = userColors.value[vac.MEMBERSEQ];
let backgroundColor = userColors.value[vac.MEMBERSEQ] || "#FFFFFF";
return { return {
title: getVacationType(vac.LOCVACTYP), title: getVacationType(vac.LOCVACTYP),
start: dateStr, start: dateStr,
backgroundColor, backgroundColor,
classNames: [getVacationTypeClass(vac.LOCVACTYP)], classNames: [getVacationTypeClass(vac.LOCVACTYP)],
saved: true, saved: true,
memberSeq: vac.MEMBERSEQ, memberSeq: vac.MEMBERSEQ,
}; };
}) }).filter(event => event.start);
.filter((event) => event.start);
return events; return events;
} else { } else {
console.warn("📌 휴가 데이터를 불러오지 못함"); console.warn("📌 휴가 데이터를 불러오지 못함");
return [];
}
} catch (error) {
console.error("Error fetching vacation data:", error);
return []; return [];
} }
} catch (error) {
console.error("Error fetching vacation data:", error);
return [];
} }
}
async function saveVacationChanges() { async function saveVacationChanges() {
if (!hasChanges.value) return; if (!hasChanges.value) return;
@ -522,7 +522,7 @@ function handleDateClick(info) {
await nextTick(); await nextTick();
fullCalendarRef.value.getApi().refetchEvents(); fullCalendarRef.value.getApi().refetchEvents();
} }
/** 오늘 이후의 날짜만 클릭 가능하도록 설정 */ /** 오늘 이후의 날짜만 클릭 가능하도록 설정 */
function markClickableDates() { function markClickableDates() {
nextTick(() => { nextTick(() => {
const todayStr = new Date().toISOString().split("T")[0]; // YYYY-MM-DD const todayStr = new Date().toISOString().split("T")[0]; // YYYY-MM-DD