Merge remote-tracking branch 'origin/main' into board-view-content

This commit is contained in:
kimdaae328 2025-02-04 16:06:43 +09:00
commit 815019e02a
10 changed files with 645 additions and 452 deletions

17
package-lock.json generated
View File

@ -17,6 +17,7 @@
"@vueup/vue-quill": "^1.2.0", "@vueup/vue-quill": "^1.2.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dompurify": "^3.2.3", "dompurify": "^3.2.3",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",
@ -1890,6 +1891,22 @@
"@popperjs/core": "^2.11.8" "@popperjs/core": "^2.11.8"
} }
}, },
"node_modules/bootstrap-icons": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz",
"integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/twbs"
},
{
"type": "opencollective",
"url": "https://opencollective.com/bootstrap"
}
],
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",

View File

@ -20,6 +20,7 @@
"@vueup/vue-quill": "^1.2.0", "@vueup/vue-quill": "^1.2.0",
"axios": "^1.7.9", "axios": "^1.7.9",
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"dompurify": "^3.2.3", "dompurify": "^3.2.3",
"flatpickr": "^4.6.13", "flatpickr": "^4.6.13",

View File

@ -1,124 +1,142 @@
<template> <template>
<div class="card mb-3 shadow-sm fixed-card" style="width: 18rem; height: 17rem"> <div class="card mb-3 shadow-sm">
<!-- 이미지가 있을 경우 card-img-top으로 표시 --> <div class="row g-0">
<img <!-- 이미지 섹션 -->
v-if="img" <div v-if="img" class="col-sm-2">
:src="img" <img
class="card-img-top" :src="img"
alt="이미지" alt="이미지"
style="object-fit: cover; height: 100px;" class="img-fluid rounded-start"
/> style="object-fit: cover; height: 100%; width: 100%;"
<div class="card-body"> />
<!-- 카테고리 태그 (존재할 경우) --> </div>
<h6 v-if="category" class="badge rounded-pill bg-primary text-white mb-2"> <!-- 게시물 내용 섹션 -->
{{ category }} <div :class="contentColClass">
</h6> <div class="card-body">
<!-- 제목과 첨부파일 아이콘 --> <!-- 태그 -->
<h5 class="card-title"> <h6 class="badge rounded-pill bg-primary text-white mb-2">
{{ title }} {{ category }}
<span class="text-muted" v-if="attachment"> </h6>
<i class="fa-solid fa-paperclip"></i> <!-- 제목 -->
</span> <h5 class="card-title">
</h5> {{ title }}
<!-- 본문 --> <span class="text-muted me-3" v-if="attachment">
<p class="card-text limit-two-lines">{{ content }}</p> <i class="fa-solid fa-paperclip"></i>
<!-- 날짜 조회수, 좋아요, 댓글 --> </span>
<p class="card-text"> </h5>
<small class="text-muted">{{ formattedDate }}</small> <!-- 본문 -->
<div class="d-flex mt-2 mt-sm-0"> <div class="card-text str_wrap my-5">{{ content }}</div>
<span class="text-muted me-3"> <!-- 날짜 -->
<i class="fa-regular fa-eye"></i> {{ views || 0 }} <div class="d-flex flex-column flex-sm-row justify-content-between align-items-start">
</span> <small class="text-muted">{{ formattedDate }}</small>
<span class="text-muted me-3" v-if="comments !== null"> <!-- 조회수, 좋아요, 댓글 -->
<i class="bx bx-comment"></i> {{ comments }} <div class="d-flex mt-2 mt-sm-0">
</span> <span class="text-muted me-3">
<span class="text-muted me-3" v-if="likes != null"> <i class="fa-regular fa-eye"></i> {{ views || 0 }}
<i class="bx bx-like"></i> {{ likes }} </span>
</span> <span class="text-muted me-3" v-if="likes != null">
<span class="text-muted me-3" v-if="dislikes != null"> <i class="bx bx-like"></i> {{ likes }}
<i class="bx bx-dislike"></i> {{ dislikes }} </span>
</span> <span class="text-muted" v-if="comments !== null">
</div> <i class="bx bx-comment"></i> {{ comments }}
</p> </span>
</div> </div>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { defineProps } from 'vue'; import { defineProps } from 'vue';
const props = defineProps({ // Props
const props = defineProps({
img: { img: {
type: String, type: String,
default: null, default: null,
}, },
category: { category: {
type: String, type: String,
required: false, required: false,
}, },
title: { title: {
type: String, type: String,
required: true, required: true,
}, },
content: { content: {
type: String, type: String,
required: true, required: true,
}, },
date: { date: {
type: String, type: String,
required: true, required: true,
}, },
views: { views: {
type: Number, type: Number,
default: 0, default: 0,
}, },
likes: { likes: {
type: Number, type: Number,
default: null, default: null,
},
dislikes: {
type: Number,
default: null,
}, },
comments: { comments: {
type: Number, type: Number,
default: null, default: null,
}, },
attachment: { attachment: {
type: Boolean, type: Boolean,
default: false, default: false,
} }
}); });
const formattedDate = computed(() => { // computed
const dateObj = new Date(props.date); const contentColClass = computed(() => {
return `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}-${String(dateObj.getDate()).padStart(2, '0')} ${String(dateObj.getHours()).padStart(2, '0')}:${String(dateObj.getMinutes()).padStart(2, '0')}`; return props.img ? 'col-sm-10 col-12' : 'col-sm-12';
}); });
</script>
<style scoped> // formattedDate computed
.card { const formattedDate = computed(() => {
const date = new Date(props.date);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
date.getDate()
).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(
date.getMinutes()
).padStart(2, "0")}`;
});
</script>
<style>
/* 카드 스타일 */
.card {
border: 1px solid #e6e6e6; border: 1px solid #e6e6e6;
border-radius: 8px; border-radius: 8px;
transition: transform 0.2s ease-in-out; transition: transform 0.2s ease-in-out;
}
.card:hover {
transform: scale(1.02);
}
/* card-img-top의 모서리 둥글게 처리 */
.card-img-top {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
/* 내용 텍스트를 두 줄로 제한 */
.limit-two-lines {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
} }
</style>
.card:hover {
transform: scale(1.02);
}
/* 텍스트 줄임 표시 */
.str_wrap {
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
/* 이미지 스타일 */
.img-fluid {
border-radius: 8px 0 0 8px;
}
/* 태그 배지 스타일 */
.badge {
font-size: 0.8rem;
padding: 5px 10px;
}
</style>

View File

@ -1,10 +1,9 @@
<template> <template>
<div class="container my-4"> <div class="mt-4">
<div class="row">
<div v-if="posts.length === 0" class="text-center"> <div v-if="posts.length === 0" class="text-center">
<p class="text-muted mt-4">게시물이 없습니다.</p> <p class="text-muted mt-4">게시물이 없습니다.</p>
</div> </div>
<div class="col-md-3 mb-4 d-flex justify-content-center" v-for="post in posts" :key="post.id" @click="handleClick(post.id)"> <div v-for="post in posts" :key="post.id" @click="handleClick(post.id)">
<BoardCard <BoardCard
:img="post.img || null" :img="post.img || null"
:category="post.category || ''" :category="post.category || ''"
@ -12,14 +11,11 @@
:content="post.content" :content="post.content"
:date="post.date" :date="post.date"
:views="post.views || 0" :views="post.views || 0"
:likes="post.likes"
:dislikes="post.dislikes"
v-bind="getBoardCardProps(post)" v-bind="getBoardCardProps(post)"
:attachment="post.attachment || false" :attachment="post.attachment || false"
@click="() => goDetail(post.id)" @click="() => goDetail(post.id)"
/> />
</div> </div>
</div>
</div> </div>
</template> </template>

View File

@ -1,18 +1,25 @@
<template> <template>
<div> <div>
<!-- 한글 버튼들 --> <ul class="alphabet-list list-unstyled d-flex flex-wrap mb-0">
<ul> <li v-for="char in koreanChars" :key="char" class="mt-2 mx-1">
<li v-for="char in koreanChars" :key="char" > <button
<button type="button" class="nav-link" @click="filterByKoreanChar(char)"> type="button"
class="btn"
:class="selectedAlphabet === char ? 'btn-primary' : 'btn-outline-primary'"
@click="selectAlphabet(char)"
>
{{ char }} {{ char }}
</button> </button>
</li> </li>
</ul> </ul>
<ul class="alphabet-list list-unstyled d-flex flex-wrap mb-0">
<!-- 영어 버튼들 --> <li v-for="char in englishChars" :key="char" class="mt-2 mx-1">
<ul> <button
<li v-for="char in englishChars" :key="char"> type="button"
<button type="button" class="nav-link" @click="filterByEnglishChar(char)"> class="btn"
:class="selectedAlphabet === char ? 'btn-primary' : 'btn-outline-primary'"
@click="selectAlphabet(char)"
>
{{ char }} {{ char }}
</button> </button>
</li> </li>
@ -21,28 +28,29 @@
</template> </template>
<script setup> <script setup>
import { defineProps } from 'vue'; import { ref } from 'vue';
const koreanChars = ['ㄱ', 'ㄴ', 'ㄷ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅅ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
const englishChars = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'];
const selectedAlphabet = ref(null);
//emit
const emit = defineEmits(); const emit = defineEmits();
const selectAlphabet = (alphabet) => {
const koreanChars = ['ㄱ', 'ㄴ', 'ㄷ', 'ㄹ']; selectedAlphabet.value = selectedAlphabet.value === alphabet ? null : alphabet;
const englishChars = ['a', 'b', 'c', 'd', 'e']; emit('update:data',selectedAlphabet.value);
//
const filterByKoreanChar = (char) => {
emit('filter', char, 'korean');
};
//
const filterByEnglishChar = (char) => {
emit('filter', char, 'english');
}; };
</script> </script>
<style scoped> <style scoped>
.nav-pills { .alphabet-list {
display: flex; margin-left: -0.25rem;
justify-content: space-between; }
margin-top: 10px;
@media (max-width: 768px) {
.alphabet-list {
overflow: scroll;
flex-wrap: nowrap !important;
}
} }
</style> </style>

View File

@ -1,5 +1,5 @@
<template> <template>
<li class="mt-5"> <li class="mt-5 card p-5">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<div class="w-100 d-flex align-items-center"> <div class="w-100 d-flex align-items-center">
<span class="btn btn-primary">{{ item.category }}</span> <span class="btn btn-primary">{{ item.category }}</span>
@ -7,7 +7,7 @@
</div> </div>
<EditBtn /> <EditBtn />
</div> </div>
<p class="mt-5">{{ item.WRDDICCON }}</p> <p class="mt-5" v-html="$common.contentToHtml(item.WRDDICCON)"></p>
<div class="d-flex justify-content-between flex-wrap gap-2 mb-2"> <div class="d-flex justify-content-between flex-wrap gap-2 mb-2">
<div class="d-flex flex-wrap align-items-center mb-50"> <div class="d-flex flex-wrap align-items-center mb-50">
<div class="avatar avatar-sm me-2"> <div class="avatar avatar-sm me-2">

View File

@ -2,57 +2,96 @@
<div class="container flex-grow-1 container-p-y"> <div class="container flex-grow-1 container-p-y">
<div class="row mb-4"> <div class="row mb-4">
<!-- 검색창 --> <!-- 검색창 -->
<div class="container col-11 px-10 "> <div class="container col-8 px-3">
<search-bar @update:data="search" /> <search-bar @update:data="search" />
</div> </div>
<!-- 글쓰기 --> <!-- 글쓰기 -->
<div class="container col-1 px-12 py-2"> <div class="container col-2 px-12 py-2">
<router-link to="/board/write"> <router-link to="/board/write">
<WriteButton /> <WriteButton />
</router-link> </router-link>
</div> </div>
</div>
<div class="row g-3"> <div class="row g-3">
<!-- 셀렉트 박스 --> <!-- 셀렉트 박스 -->
<div class="col-12 col-md-auto"> <div class="col-12 col-md-auto">
<select class="form-select" v-model="selectedOrder" @change="handleSortChange"> <select class="form-select" v-model="selectedOrder" @change="handleSortChange">
<option value="date">최신날짜</option> <option value="date">최신날짜</option>
<option value="views">조회수</option> <option value="views">조회수</option>
</select> </select>
</div>
</div> </div>
<!-- 공지 접기 기능 -->
<!-- 공지사항 리스트 --> <div class="container col-1 px-0 py-2">
<div v-if="pagination.currentPage === 1" class="mb-8"> <label>
<div class="row"> <input type="checkbox" v-model="showNotices" /> 공지 숨기기
<BoardCardList :posts="noticeList" @click="goDetail" /> </label>
</div>
<!-- 리스트 갯수 선택 -->
<div class="container col-1 px-0 py-2">
<select class="form-select" v-model="selectedSize" @change="handleSizeChange">
<option value="10">10개씩</option>
<option value="20">20개씩</option>
<option value="30">30개씩</option>
<option value="50">50개씩</option>
</select>
</div> </div>
</div> </div>
</div>
<!-- 일반 리스트 --> <br>
<div> <!-- 게시판 -->
<div class="row"> <div class="table-responsive">
<BoardCardList :posts="generalList" @click="goDetail" /> <table class="table table-bordered">
</div> <thead class="table-light">
<tr>
<th style="width: 8%;">번호</th>
<th style="width: 50%;">제목</th>
<th style="width: 15%;">작성자</th>
<th style="width: 12%;">작성일</th>
<th style="width: 10%;">조회수</th>
</tr>
</thead>
<tbody>
<!-- 공지사항 ( 페이지에서만 표시, showNotices가 false일 때만 보임) -->
<template v-if="pagination.currentPage === 1 && !showNotices">
<tr v-for="(notice, index) in noticeList"
:key="'notice-' + index"
class="notice-row clickable-row"
@click="goDetail(notice.id)">
<td>공지</td>
<td>
📌 {{ notice.title }}
<i v-if="notice.hasAttachment" class="bi bi-paperclip"></i>
<span v-if="isNewPost(notice.date)" class="badge bg-danger text-white ms-2 new-badge">N</span>
</td>
<td>{{ notice.author }}</td>
<td>{{ notice.date }}</td>
<td>{{ notice.views }}</td>
</tr>
</template>
<!-- 일반 게시물 -->
<tr v-for="(post, index) in generalList"
:key="'post-' + index"
class="general-row clickable-row"
@click="goDetail(post.id)">
<td>{{ post.id }}</td>
<td>
{{ post.title }}
<i v-if="post.hasAttachment" class="bi bi-paperclip"></i>
<span v-if="isNewPost(post.date)" class="badge bg-danger text-white ms-2 new-badge">N</span>
</td>
<td>{{ post.author }}</td>
<td>{{ post.date }}</td>
<td>{{ post.views }}</td>
</tr>
</tbody>
</table>
</div> </div>
<!-- 페이지네이션 --> <!-- 페이지네이션 -->
<div class="row g-3"> <div class="row g-3">
<div class="mt-8"> <div class="mt-8">
<Pagination <Pagination
:currentPage="pagination.currentPage" v-bind="pagination"
:pages="pagination.pages"
:prePage="pagination.prePage"
:nextPage="pagination.nextPage"
:isFirstPage="pagination.isFirstPage"
:isLastPage="pagination.isLastPage"
:hasPreviousPage="pagination.hasPreviousPage"
:hasNextPage="pagination.hasNextPage"
:navigatePages="pagination.navigatePages"
:navigatepageNums="pagination.navigatepageNums"
:navigateFirstPage="pagination.navigateFirstPage"
:navigateLastPage="pagination.navigateLastPage"
@update:currentPage="handlePageChange" @update:currentPage="handlePageChange"
/> />
</div> </div>
@ -62,19 +101,28 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue'; import { ref, computed, onMounted } from 'vue';
import BoardCardList from '@/components/list/BoardCardList.vue';
import Pagination from '@c/pagination/Pagination.vue'; import Pagination from '@c/pagination/Pagination.vue';
import SearchBar from '@c/search/SearchBar.vue'; import SearchBar from '@c/search/SearchBar.vue';
import router from '@/router'; import router from '@/router';
import WriteButton from '@c/button/WriteBtn.vue'; import WriteButton from '@c/button/WriteBtn.vue';
import axios from '@api'; import axios from '@api';
import dayjs from 'dayjs';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
import 'bootstrap-icons/font/bootstrap-icons.css';
dayjs.extend(isToday); //
dayjs.extend(isYesterday);
// //
const generalList = ref([]); const generalList = ref([]);
const noticeList = ref([]); const noticeList = ref([]);
const searchText = ref(''); const searchText = ref('');
const selectedOrder = ref('date'); const selectedOrder = ref('date');
const sortDirection = ref('desc'); const selectedSize = ref(10);
const showNotices = ref(false);
const pagination = ref({ const pagination = ref({
currentPage: 1, currentPage: 1,
pages: 1, pages: 1,
@ -95,83 +143,96 @@ const goDetail = (id) => {
router.push({ name: 'BoardDetail', params: { id } }); router.push({ name: 'BoardDetail', params: { id } });
}; };
// // ( HH:mm, YYYY-MM-DD)
const formatDate = (dateString) => {
const date = dayjs(dateString);
return date.isToday() ? date.format('HH:mm') : date.format('YYYY-MM-DD');
};
// ( )
const isNewPost = (dateString) => {
const date = dayjs(dateString);
return date.isToday() || date.isYesterday();
};
// ( )
const search = (e) => { const search = (e) => {
searchText.value = e.trim(); searchText.value = e.trim();
fetchGeneralPosts(1); fetchGeneralPosts(1);
fetchNoticePosts(searchText.value);
}; };
// // ( )
const handleSortChange = () => { const handleSortChange = () => {
fetchGeneralPosts(1); fetchGeneralPosts(1);
}; };
// () //
const handleSizeChange = () => {
fetchGeneralPosts(1);
};
//
const fetchGeneralPosts = async (page = 1) => { const fetchGeneralPosts = async (page = 1) => {
const response = await axios.get("board/general", { try {
params: { const { data } = await axios.get("board/general", {
page: page, params: {
orderBy: selectedOrder.value, page,
sortDirection: sortDirection.value, size: selectedSize.value,
searchKeyword: searchText.value orderBy: selectedOrder.value,
searchKeyword: searchText.value
}
});
if (data?.data) {
generalList.value = data.data.list.map(post => ({
id: post.id,
title: post.title,
author: post.author || '익명',
date: formatDate(post.date), //
views: post.cnt || 0,
hasAttachment: post.hasAttachment || false
}));
pagination.value = {
...pagination.value,
currentPage: data.data.pageNum,
pages: data.data.pages,
prePage: data.data.prePage,
nextPage: data.data.nextPage,
isFirstPage: data.data.isFirstPage,
isLastPage: data.data.isLastPage,
hasPreviousPage: data.data.hasPreviousPage,
hasNextPage: data.data.hasNextPage,
navigatePages: data.data.navigatePages,
navigatepageNums: data.data.navigatepageNums,
navigateFirstPage: data.data.navigateFirstPage,
navigateLastPage: data.data.navigateLastPage
};
} }
}); } catch (error) {
console.error("데이터 오류:", error);
if (response.data && response.data.data) {
const data = response.data.data;
//
generalList.value = data.list.map((post, index) => ({
...post,
id: post.id || index,
img: post.firstImageUrl || null,
views: post.cnt || 0,
likes: post.likeCount != null ? post.dislikeCount : null,
dislikes: post.dislikeCount != null ? post.likeCount : null,
comments: post.commentCount != null ? post.commentCount : null,
attachment: post.hasAttachment || false,
content: post.plainContent || '',
}));
//
pagination.value = {
currentPage: data.pageNum,
pages: data.pages,
prePage: data.prePage,
nextPage: data.nextPage,
isFirstPage: data.isFirstPage,
isLastPage: data.isLastPage,
hasPreviousPage: data.hasPreviousPage,
hasNextPage: data.hasNextPage,
navigatePages: data.navigatePages,
navigatepageNums: data.navigatepageNums,
navigateFirstPage: data.navigateFirstPage,
navigateLastPage: data.navigateLastPage
};
} else {
console.error("데이터 오류:", response.data);
} }
}; };
// () //
const fetchNoticePosts = async () => { const fetchNoticePosts = async () => {
const response = await axios.get("board/notices", { try {
params: { const { data } = await axios.get("board/notices", {
searchKeyword: searchText.value params: { searchKeyword: searchText.value }
} });
});
if (response.data && response.data.data && Array.isArray(response.data.data)) { if (data?.data) {
noticeList.value = response.data.data.map((post, index) => ({ noticeList.value = data.data.map(post => ({
...post, id: post.id,
id: post.id || index, title: post.title,
img: post.firstImageUrl || null, author: post.author || '관리자',
views: post.cnt || 0, date: formatDate(post.date), //
attachment: post.hasAttachment || false, views: post.cnt || 0,
content: post.plainContent || '', hasAttachment: post.hasAttachment || false
})); }));
} else { }
console.error("데이터 오류:", response.data); } catch (error) {
console.error("데이터 오류:", error);
} }
}; };
@ -184,7 +245,40 @@ const handlePageChange = (page) => {
// //
onMounted(() => { onMounted(() => {
fetchGeneralPosts();
fetchNoticePosts(); fetchNoticePosts();
fetchGeneralPosts();
}); });
</script> </script>
<style scoped>
/* 공지사항 스타일 */
.notice-row {
background-color: #f8f9fa;
}
.notice-row td {
color: #DC3545 !important;
}
/* 일반 게시물 스타일 */
.general-row {
background-color: white;
color: black;
}
/* 행 전체 클릭 가능 */
.clickable-row {
cursor: pointer;
}
.clickable-row:hover {
background-color: #f1f1f1;
}
/* 새 글 아이콘 크기 조정 */
.new-badge {
font-size: 0.65rem; /* 원래 크기의 약 절반 */
padding: 0.2em 0.4em; /* 배지 크기 조정 */
vertical-align: middle; /* 텍스트 정렬 */
}
</style>

View File

@ -22,7 +22,7 @@
<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="(categoryName, index) in categoryList" v-for="(category, index) in categoryList"
:key="index" :key="index"
class="form-check me-3" class="form-check me-3"
> >
@ -30,18 +30,19 @@
class="form-check-input" class="form-check-input"
type="radio" type="radio"
:id="`category-${index}`" :id="`category-${index}`"
:value="index" :value="category.CMNCODVAL"
v-model="category" v-model="categoryValue"
/> />
<label class="form-check-label" :for="`category-${index}`"> <label class="form-check-label" :for="`category-${index}`">
{{ categoryName }} {{ category.CMNCODNAM }}
</label> </label>
</div> </div>
</div> </div>
<div class="invalid-feedback" :class="categoryAlert ? 'display-block' : ''">카테고리를 선택해주세요.</div>
</div> </div>
<!-- 비밀번호 필드 --> <!-- 비밀번호 필드 -->
<div v-if="category === 1" class="mb-4"> <div v-if="categoryValue === 300102" class="mb-4">
<FormInput <FormInput
title="비밀번호" title="비밀번호"
name="pw" name="pw"
@ -94,25 +95,39 @@
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 { getCurrentInstance, ref } from 'vue'; import { getCurrentInstance, ref, onMounted } from 'vue';
import router from '@/router'; import router from '@/router';
import axios from '@api'; import axios from '@api';
const categoryList = ['자유', '익명', '공지사항']; // const categoryList = ref([]);
const title = ref(''); const title = ref('');
const password = ref(''); const password = ref('');
const category = ref(0); // 0 const categoryValue = ref(null);
const content = ref(''); const content = ref('');
const attachFiles = ref(null); const attachFiles = ref(null);
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 attachFilesAlert = ref(false); const attachFilesAlert = ref(false);
const { appContext } = getCurrentInstance(); const { appContext } = getCurrentInstance();
const $common = appContext.config.globalProperties.$common; // $common const $common = appContext.config.globalProperties.$common;
const fetchCategories = async () => {
try {
const response = await axios.get('board/categories');
categoryList.value = response.data.data;
} catch (error) {
console.error('카테고리 불러오기 오류:', error);
}
};
onMounted(() => {
fetchCategories();
});
const goList = () => { const goList = () => {
router.push('/board'); router.push('/board');
@ -120,10 +135,11 @@ const goList = () => {
const write = async () => { const write = async () => {
titleAlert.value = !title.value; titleAlert.value = !title.value;
passwordAlert.value = category.value === 1 && !password.value; passwordAlert.value = categoryValue.value === 300102 && !password.value;
contentAlert.value = !content.value; contentAlert.value = !content.value;
categoryAlert.value = !categoryValue.value;
if (titleAlert.value || passwordAlert.value || contentAlert.value || !isFileValid.value) { if (titleAlert.value || passwordAlert.value || contentAlert.value || categoryAlert.value || !isFileValid.value) {
return; return;
} }
@ -131,8 +147,8 @@ const write = async () => {
const boardData = { const boardData = {
LOCBRDTTL: title.value, LOCBRDTTL: title.value,
LOCBRDCON: $common.deltaAsJson(content.value), LOCBRDCON: $common.deltaAsJson(content.value),
LOCBRDPWD: category.value === 1 ? password.value : null, LOCBRDPWD: categoryValue.value === 300102 ? password.value : null,
LOCBRDTYP: category.value === 1 ? 'S' : 'F', LOCBRDTYP: categoryValue.value
}; };
const { data: boardResponse } = await axios.post('board', boardData); const { data: boardResponse } = await axios.post('board', boardData);

View File

@ -1,75 +1,63 @@
<template> <template>
<div class="vacation-management"> <div class="vacation-management">
<div class="container-xxl flex-grow-1 container-p-y"> <div class="container-xxl flex-grow-1 container-p-y">
<!-- 저장 버튼 --> <div class="save-button-container">
<div class="save-button-container"> <button class="btn btn-success" @click="addVacationRequests"> 저장</button>
<button class="btn btn-success" @click="addVacationRequests"> 저장</button>
</div>
<!-- 캘린더 -->
<div class="card app-calendar-wrapper">
<div class="row g-0">
<div class="col app-calendar-content">
<div class="card shadow-none border-0">
<div class="card-body pb-0">
<full-calendar
ref="fullCalendarRef"
:events="calendarEvents"
:options="calendarOptions"
defaultView="dayGridMonth"
class="flatpickr-calendar-only"
/>
</div>
</div>
<!-- 오전/오후 반차 버튼 -->
<div class="half-day-buttons">
<button
class="btn btn-info"
:class="{ active: halfDayType === 'AM' }"
@click="toggleHalfDay('AM')"
>
오전반차
</button>
<button
class="btn btn-warning"
:class="{ active: halfDayType === 'PM' }"
@click="toggleHalfDay('PM')"
>
🌙 오후반차
</button>
</div>
</div>
</div>
</div>
</div> </div>
<div class="card app-calendar-wrapper">
<div class="row g-0">
<div class="col app-calendar-content">
<div class="card shadow-none border-0">
<div class="card-body pb-0">
<full-calendar
ref="fullCalendarRef"
:options="calendarOptions"
class="flatpickr-calendar-only"
/>
</div>
</div>
<div class="half-day-buttons">
<button class="btn btn-info" :class="{ active: halfDayType === 'AM' }" @click="toggleHalfDay('AM')">
오전반차
</button>
<button class="btn btn-warning" :class="{ active: halfDayType === 'PM' }" @click="toggleHalfDay('PM')">
오후반차
</button>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import FullCalendar from '@fullcalendar/vue3'; import FullCalendar from "@fullcalendar/vue3";
import dayGridPlugin from '@fullcalendar/daygrid'; import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from '@fullcalendar/interaction'; import interactionPlugin from "@fullcalendar/interaction";
import 'flatpickr/dist/flatpickr.min.css'; import "flatpickr/dist/flatpickr.min.css";
import '@/assets/css/app-calendar.css'; import "@/assets/css/app-calendar.css";
import { reactive, ref, onMounted } from 'vue'; import { reactive, ref, onMounted } from "vue";
import axios from '@api'; // Axios import axios from "@api";
const fullCalendarRef = ref(null); const fullCalendarRef = ref(null);
const calendarEvents = ref([]); const calendarEvents = ref([]); // FullCalendar
const selectedDates = ref(new Map()); const selectedDates = ref(new Map());
const halfDayType = ref(null); const halfDayType = ref(null);
const employeeId = ref(1); const employeeId = ref(1);
const calendarOptions = reactive({ const calendarOptions = reactive({
plugins: [dayGridPlugin, interactionPlugin], plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth', initialView: "dayGridMonth",
headerToolbar: { headerToolbar: {
left: 'today', left: "today",
center: 'title', center: "title",
right: 'prev,next', right: "prev,next",
}, },
locale: 'ko', locale: "ko",
selectable: true, selectable: true,
dateClick: handleDateClick, dateClick: handleDateClick,
events: calendarEvents, //
}); });
/** /**
@ -77,25 +65,12 @@ const calendarOptions = reactive({
*/ */
function handleDateClick(info) { function handleDateClick(info) {
const date = info.dateStr; const date = info.dateStr;
const dayElement = info.dayEl;
if (!selectedDates.value.has(date)) { if (!selectedDates.value.has(date)) {
const type = halfDayType.value ? (halfDayType.value === 'AM' ? 'D' : 'N') : 'F'; const type = halfDayType.value ? (halfDayType.value === "AM" ? "D" : "N") : "F";
selectedDates.value.set(date, type); selectedDates.value.set(date, type);
if (type === 'D') {
dayElement.style.backgroundImage = 'linear-gradient(to bottom, #ade3ff 50%, transparent 50%)';
} else if (type === 'N') {
dayElement.style.backgroundImage = 'linear-gradient(to top, #ade3ff 50%, transparent 50%)';
} else {
dayElement.style.backgroundColor = '#ade3ff';
}
} else { } else {
selectedDates.value.delete(date); selectedDates.value.delete(date);
dayElement.style.backgroundColor = '';
dayElement.style.backgroundImage = '';
} }
halfDayType.value = null; halfDayType.value = null;
} }
@ -106,72 +81,60 @@ function toggleHalfDay(type) {
halfDayType.value = halfDayType.value === type ? null : type; halfDayType.value = halfDayType.value === type ? null : type;
} }
/**
* 백엔드에서 휴가 데이터를 가져와 FullCalendar에 반영
*/
async function fetchVacationData() { async function fetchVacationData() {
try { try {
const response = await axios.get('vacation/list'); const response = await axios.get("vacation/list");
if (response.data.status === 'OK') {
if (response.data.status === "OK") {
const vacationList = response.data.data; const vacationList = response.data.data;
console.log("📌 백엔드 응답 데이터:", vacationList);
// if (!Array.isArray(vacationList)) {
const employeeVacations = new Map(); throw new Error("vacationList is not an array.");
}
vacationList.forEach(({ employeeId, date, type }) => { //
if (!employeeVacations.has(employeeId)) { const events = vacationList.map((vac) => {
employeeVacations.set(employeeId, []); let dateStr = vac.LOCVACUDT.split("T")[0];
let className = "fc-daygrid-event";
let title = "연차";
if (vac.LOCVACTYP === "D") {
title = "오전반차";
className += " half-day-am";
} else if (vac.LOCVACTYP === "N") {
title = "오후반차";
className += " half-day-pm";
} else if (vac.LOCVACTYP === "F") {
title = "연차";
className += " full-day";
} }
employeeVacations.get(employeeId).push({ date, type });
});
// return {
employeeVacations.forEach((dates, employeeId) => { title,
const sortedDates = dates.map(d => new Date(d.date)).sort((a, b) => a - b); start: dateStr,
const color = getColorByEmployeeId(employeeId); // backgroundColor: getColorByEmployeeId(vac.MEMBERSEQ),
classNames: [className],
};
}).filter((event) => event !== null);
let previousDate = null; console.log("📌 변환된 이벤트:", events);
calendarEvents.value = events; // FullCalendar
sortedDates.forEach(currentDate => {
const dateStr = currentDate.toISOString().split('T')[0];
const dayElement = document.querySelector(`[data-date="${dateStr}"]`);
if (dayElement) {
if (
previousDate &&
currentDate - previousDate === 86400000 //
) {
//
dayElement.style.backgroundColor = color;
} else {
//
dayElement.style.backgroundColor = color;
dayElement.style.borderLeft = `3px solid ${color}`;
}
previousDate = currentDate;
}
});
});
} }
} catch (error) { } catch (error) {
console.error('Error fetching vacation data:', error); console.error("Error fetching vacation data:", error);
} }
} }
/**
* 사원 ID별 색상 반환 함수
*/
function getColorByEmployeeId(employeeId) {
const colors = ['#ade3ff', '#ffade3', '#ade3ad', '#ffadad'];
return colors[employeeId % colors.length];
}
//
fetchVacationData();
/** /**
* 휴가 요청 추가 * 휴가 요청 추가
*/ */
async function addVacationRequests() { async function addVacationRequests() {
if (selectedDates.value.size === 0) { if (selectedDates.value.size === 0) {
alert('휴가를 선택해주세요.'); alert("휴가를 선택해주세요.");
return; return;
} }
@ -182,50 +145,41 @@ async function addVacationRequests() {
})); }));
try { try {
const response = await axios.post('vacation', vacationRequests); const response = await axios.post("vacation", vacationRequests);
if (response.data && response.data.status === 'OK') { if (response.data && response.data.status === "OK") {
alert('휴가가 저장되었습니다.'); alert("휴가가 저장되었습니다.");
fetchVacationData(); //
const newEvents = vacationRequests.map(req => ({
title: req.type === 'D' ? '오전반차' : req.type === 'N' ? '오후반차' : '종일 휴가',
start: req.date,
allDay: true,
}));
calendarEvents.value = [...calendarEvents.value, ...newEvents];
selectedDates.value.clear(); selectedDates.value.clear();
resetCalendarStyles();
} else { } else {
alert('휴가 저장 중 오류가 발생했습니다.'); alert("휴가 저장 중 오류가 발생했습니다.");
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
alert('휴가 저장에 실패했습니다.'); alert("휴가 저장에 실패했습니다.");
} }
} }
/** /**
* 초기화 * 사원 ID별 색상 반환
*/ */
function resetCalendarStyles() { function getColorByEmployeeId(employeeId) {
const calendarElements = document.querySelectorAll('.fc-daygrid-day'); const colors = ["#ade3ff", "#ffade3", "#ade3ad", "#ffadad"];
calendarElements.forEach(element => { return colors[employeeId % colors.length];
element.style.backgroundColor = '';
element.style.backgroundImage = '';
});
} }
//
onMounted(() => { onMounted(() => {
fetchVacationData(); fetchVacationData();
}); });
</script> </script>
<style scoped> <style>
.vacation-management { .vacation-management {
padding: 20px; padding: 20px;
} }
/* 버튼 스타일 */
.half-day-buttons { .half-day-buttons {
display: flex; display: flex;
justify-content: center; justify-content: center;
@ -236,4 +190,48 @@ onMounted(() => {
.half-day-buttons .btn.active { .half-day-buttons .btn.active {
border: 2px solid black; border: 2px solid black;
} }
/* FullCalendar 이벤트 스타일 */
.fc-daygrid-event {
position: absolute !important;
height: 20px !important; /* 실선 두께 */
width: 90% !important;
left: 5% !important;
margin: 2px 0 !important;
padding: 0 !important;
border-radius: 2px !important;
background-color: inherit !important;
border: none !important; /* 기본 FullCalendar 테두리 제거 */
}
/* 여러 이벤트가 같은 날짜에 있을 때 정렬 */
.fc-daygrid-event-harness {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
gap: 3px;
}
/* 오전반차(왼쪽부터 중앙까지) */
.fc-daygrid-event.half-day-am {
width: 45% !important;
left: 0% !important;
background-color: #ffdd57 !important; /* 노란색 */
}
/* 오후반차(오른쪽부터 중앙까지) */
.fc-daygrid-event.half-day-pm {
width: 45% !important;
left: auto !important;
right: 0% !important;
background-color: #57a5ff !important; /* 파란색 */
}
/* 연차 (전체 너비) */
.fc-daygrid-event.full-day {
width: 100% !important;
left: 0 !important;
background-color: #ff85a2 !important; /* 연한 빨강 */
}
</style> </style>

View File

@ -1,26 +1,26 @@
<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"> <div class="card p-5">
<!-- 타이틀, 검색 --> <!-- 타이틀, 검색 -->
<div class="row mt-4"> <div class="row">
<div class="col-6"> <div class="col-12 col-md-6">
<h5 class="mb-0">용어집</h5> <h5 class="mb-0 title">용어집</h5>
</div> </div>
<div class="col-6"> <div class="col-12 col-md-6">
<SearchBar @update:data="search"/> <SearchBar @update:data="search"/>
</div> </div>
</div> </div>
<!-- 단어 갯수, 작성하기 --> <!-- 단어 갯수, 작성하기 -->
<div class="mt-4"> <div class="mt-4">
단어 : {{ filteredList.length }} 단어 : {{ total }}
<WriteButton @click="toggleWriteForm" /> <WriteButton @click="toggleWriteForm" />
</div> </div>
<!-- --> <!-- -->
<div> <div>
<DictAlphabetFilter/> <DictAlphabetFilter @update:data="handleSelectedAlphabetChange" />
</div> </div>
<!-- 카테고리 --> <!-- 카테고리 -->
@ -32,36 +32,43 @@
<div v-if="isWriteVisible" class="mt-5"> <div v-if="isWriteVisible" class="mt-5">
<DictWrite @close="isWriteVisible = false" /> <DictWrite @close="isWriteVisible = false" />
</div> </div>
<!-- 용어 리스트 -->
<div class="mt-10">
<!-- 로딩 중일 -->
<div v-if="loading">로딩 중...</div>
<!-- 에러 메시지 -->
<div v-if="error" class="error">{{ error }}</div>
<!-- 단어 목록 -->
<ul v-if="filteredList.length" class="px-0 list-unstyled">
<DictCard
v-for="item in filteredList"
:key="item.WRDDICSEQ"
:item="item"
/>
</ul>
<!-- 데이터가 없을 -->
<div v-else-if="!loading && !error">용어집의 용어가 없습니다.</div>
</div>
</div> </div>
<!-- 용어 리스트 -->
<div class="mt-10">
<!-- 로딩 중일 -->
<div v-if="loading">로딩 중...</div>
<!-- 에러 메시지 -->
<div v-if="error" class="error">{{ error }}</div>
<!-- 단어 목록 -->
<ul v-if="total > 0" class="px-0 list-unstyled">
<DictCard
v-for="item in wordList"
:key="item.WRDDICSEQ"
:item="item"
/>
</ul>
<!-- <ul v-if="wordList.length > 0" class="px-0 list-unstyled">
<DictCard
v-for="item in wordList"
:key="item.WRDDICSEQ"
:item="item"
/>
</ul> -->
<!-- 데이터가 없을 -->
<div v-else-if="!loading && !error">용어집의 용어가 없습니다.</div>
</div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, watchEffect, computed } from 'vue'; import { ref, watchEffect, computed, onMounted } from 'vue';
import axios from '@api'; import axios from '@api';
import SearchBar from '@c/search/SearchBar.vue'; import SearchBar from '@c/search/SearchBar.vue';
import WriteButton from '@c/button/WriteBtn.vue'; import WriteButton from '@c/button/WriteBtn.vue';
@ -77,72 +84,110 @@
// //
const wordList = ref([]); const wordList = ref([]);
//
const total = ref(0);
// //
const cateList = ref([]); const cateList = ref([]);
//
const selectedAlphabet = ref('');
// //
const searchText = ref(''); const searchText = ref('');
// //
const isWriteVisible = ref(false); const isWriteVisible = ref(false);
// API //
const fetchAllData = async () => { onMounted(() => {
loading.value = true; getwordList(); //
error.value = ''; getwordCategory(); //
try { });
// //
const wordResponse = await axios.get('worddict/getWordList'); const getwordList = (searchKeyword='',indexKeyword='') => {
wordList.value = wordResponse.data.data.data; axios.get('worddict/getWordList',{
console.log('용어집 데이터:', wordList.value); //
// params: { searchKeyword: searchKeyword
const categoryResponse = await axios.get('worddict/getWordCategory'); ,indexKeyword:indexKeyword
cateList.value = categoryResponse.data.data; }
console.log('카테고리 데이터:', cateList.value); })
} catch (err) { .then(res => {
error.value = '데이터를 가져오는 중 문제가 발생했습니다.'; wordList.value = res.data.data.data; //
} finally { total.value = res.data.data.total; //
loading.value = false; loading.value = false;
} })
.catch(err => {
console.error('데이터 로드 오류:', err);
error.value = '데이터를 가져오는 중 문제가 발생했습니다.';
loading.value = false; //
});
};
//
const getwordCategory = () => {
axios.get('worddict/getWordCategory')
.then(res => {
cateList.value = res.data.data; //
})
.catch(err => {
console.error('카테고리 로드 오류:', err);
error.value = '카테고리 데이터를 가져오는 중 문제가 발생했습니다.';
});
};
const handleSelectedAlphabetChange = (newAlphabet) => {
selectedAlphabet.value = newAlphabet;
getwordList(searchText.value,selectedAlphabet.value);
}; };
// //
const search = (e) => { const search = (e) => {
const trimmedSearchText = e.trim(); searchText.value = e.trim();
if (trimmedSearchText.length === 0) { getwordList(searchText.value,selectedAlphabet.value);
alert('검색어를 입력해주세요.');
return;
}
searchText.value = trimmedSearchText;
}; };
// API
// const fetchAllData = async () => {
// loading.value = true;
// error.value = '';
// try {
// //
// // const wordResponse = await axios.get('worddict/getWordList');
// //wordList.value = wordResponse.data.data.data;
// //console.log(' :', wordList.value);
// //
// const categoryResponse = await axios.get('worddict/getWordCategory');
// cateList.value = categoryResponse.data.data;
// console.log(' :', cateList.value);
// } catch (err) {
// error.value = ' .';
// } finally {
// loading.value = false;
// }
// };
// //
const filteredList = computed(() => // const filteredList = computed(() =>
wordList.value.filter(item => // wordList.value.filter(item =>
item.WRDDICTTL.toLowerCase().includes(searchText.value.toLowerCase()) // item.WRDDICTTL.toLowerCase().includes(searchText.value.toLowerCase())
) // )
); // );
// toggle // toggle
const toggleWriteForm = () => { const toggleWriteForm = () => {
isWriteVisible.value = !isWriteVisible.value; isWriteVisible.value = !isWriteVisible.value;
}; };
// `watchEffect` API
watchEffect(() => {
fetchAllData();
});
</script> </script>
<style scoped> <style scoped>
.card > div {
padding: 0 30px
}
.error { .error {
color: red; color: red;
font-weight: bold; font-weight: bold;
} }
@media (max-width: 768px) {
.title {
margin-bottom: 0.5rem !important;
}
}
</style> </style>