Merge branch 'main' into project-list

This commit is contained in:
yoon 2025-02-18 15:05:30 +09:00
commit 1b3339fa94
26 changed files with 968 additions and 266 deletions

View File

@ -68,7 +68,7 @@
<script setup>
import { defineProps, defineEmits, ref } from 'vue';
import BoardProfile from './BoardProfile.vue';
import BoardCommentArea from './BoardComentArea.vue';
import BoardCommentArea from './BoardCommentArea.vue';
import PlusButton from '../button/PlusBtn.vue';
const props = defineProps({

View File

@ -5,7 +5,7 @@
<div class="d-flex justify-content-start align-items-top">
<!-- 프로필섹션 -->
<div class="avatar-wrapper">
<div class="avatar me-4">
<div v-if="!unknown" class="avatar me-4">
<img src="/img/avatars/11.png" alt="Avatar" class="rounded-circle">
</div>
</div>
@ -13,8 +13,9 @@
<div class="w-100">
<textarea
class="form-control"
placeholder="주제에 대한 생각을 자유롭게 댓글로 표현해 주세요. &#13;&#10;여러분의 다양한 의견을 기다립니다."
placeholder="댓글 달기"
rows="3"
v-model="comment"
></textarea>
</div>
</div>
@ -23,7 +24,7 @@
<div class="d-flex justify-content-between flex-wrap mt-4">
<div class="d-flex flex-wrap align-items-center">
<!-- 익명 체크박스 (익명게시판일 경우에만)-->
<div class="form-check form-check-inline mb-0 me-4">
<div v-if="unknown" class="form-check form-check-inline mb-0 me-4">
<input
class="form-check-input"
type="checkbox"
@ -40,15 +41,16 @@
type="password"
id="basic-default-password"
class="form-control flex-grow-1"
placeholder=""
v-model="password"
/>
</div>
</div>
<!-- 답변 쓰기 버튼 -->
<div class="ms-auto mt-3 mt-md-0">
<button class="btn btn-primary">
<i class="icon-base bx bx-check"></i>
<button class="btn btn-primary" @click="handleCommentSubmit">
<!-- <i class="icon-base bx bx-check"></i> -->
확인
</button>
</div>
</div>
@ -57,6 +59,40 @@
</template>
<script setup>
import { ref } from 'vue';
import { ref, defineEmits, defineProps, computed, watch } from 'vue';
const props = defineProps({
unknown: {
type: Boolean,
default: true,
},
parentId: {
type: Number,
default: 0
}
});
const comment = ref('');
const password = ref('');
const isCheck = ref(false);
const emit = defineEmits(['submitComment']);
watch(() => props.unknown, (newVal) => {
if (!newVal) {
isCheck.value = false;
}
});
function handleCommentSubmit() {
emit('submitComment', {
comment: comment.value,
password: password.value,
});
comment.value = '';
password.value = '';
}
</script>

View File

@ -46,7 +46,6 @@
</div>
<!-- 에디터가 표시될 div -->
<div ref="editor"></div>
<!-- Alert 메시지 표시 -->
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">내용을 확인해주세요.</div>
</div>
@ -64,6 +63,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
initialData: {
type: String,
default: () => null,
},
});
const editor = ref(null); // DOM
@ -108,8 +111,14 @@ onMounted(() => {
watch([font, fontSize], () => {
quillInstance.format('font', font.value);
quillInstance.format('size', fontSize.value);
});
// , HTML
if (props.initialData) {
quillInstance.setContents(JSON.parse(props.initialData));
}
//
let imageUrls = new Set(); // URL
quillInstance.getModule('toolbar').addHandler('image', () => {

View File

@ -8,13 +8,13 @@
<p>해당 직원에게 부여할 연차 개수를 선택하세요. (남은 개수: {{ availableQuota }})</p>
<div class="vacation-control">
<button @click="decreaseCount" :disabled="grantCount <= 0" class="count-btn">-</button>
<button @click="decreaseCount" :disabled="grantCount < 2" class="count-btn">-</button>
<span class="grant-count">{{ grantCount }}</span>
<button @click="increaseCount" :disabled="grantCount >= availableQuota" class="count-btn">+</button>
</div>
<button class="gift-btn" @click="saveVacationGrant" :disabled="grantCount === 0">
<i class="bx bx-gift"></i> <!-- 선물상자 아이콘 -->
<i class="bx bx-gift"></i>
</button>
</div>
</div>
@ -26,65 +26,60 @@
import axios from "@api";
const props = defineProps({
isOpen: Boolean, //
targetUser: Object, //
isOpen: Boolean,
targetUser: Object,
});
const emit = defineEmits(["close", "updateVacation"]);
const grantCount = ref(0);
const maxQuota = 2; // 1
const sentCount = ref(0); //
const availableQuota = ref(2); //
const maxQuota = 2;
const sentCount = ref(0);
const availableQuota = ref(2);
//
const fetchSentVacationCount = async () => {
try {
const payload = { receiverId: props.targetUser.MEMBERSEQ };
const response = await axios.get(`vacation/sent`,{ params: payload });
console.log(response.data.data[0].count)
sentCount.value = response.data.data[0].count || 0; //
availableQuota.value = Math.max(maxQuota - sentCount.value, 0); // (0 )
console.log(`✅ 보낸 개수: ${sentCount.value}, 남은 개수: ${availableQuota.value}`);
const response = await axios.get("vacation/sent", { params: payload });
sentCount.value = response.data.data[0].count || 0;
availableQuota.value = Math.max(maxQuota - sentCount.value, 0);
grantCount.value = availableQuota.value; //
} catch (error) {
console.error("🚨 연차 전송 기록 조회 실패:", error);
availableQuota.value = maxQuota;
grantCount.value = maxQuota; //
}
};
//
const increaseCount = () => {
if (grantCount.value < availableQuota.value) {
grantCount.value++;
}
};
//
const decreaseCount = () => {
if (grantCount.value > 0) {
grantCount.value--;
}
};
// (saveVacations API )
const saveVacationGrant = async () => {
try {
const payload = [
{
date: new Date().toISOString().split("T")[0], //
type: "700103", //
senderId: props.targetUser.senderId, // ID
receiverId: props.targetUser.MEMBERSEQ, // ID
count: grantCount.value, //
date: new Date().toISOString().split("T")[0],
type: "700103",
senderId: props.targetUser.senderId,
receiverId: props.targetUser.MEMBERSEQ,
count: grantCount.value,
},
];
const response = await axios.post("vacation/save", payload);
const response = await axios.post("vacation", payload);
console.log(response)
if (response.data && response.data.status === "OK") {
alert("✅ 연차가 부여되었습니다.");
await fetchSentVacationCount(); //
emit("updateVacation"); //
await fetchSentVacationCount();
emit("updateVacation");
closeModal();
} else {
alert("🚨 연차 추가 중 오류가 발생했습니다.");
@ -95,36 +90,30 @@
}
};
//
const closeModal = () => {
emit("close");
};
//
watch(
() => props.isOpen,
async (newVal) => {
if (newVal && props.targetUser && props.targetUser.MEMBERSEQ) {
console.log("🟢 모달이 열렸습니다. 데이터를 로드합니다.");
grantCount.value = 0; //
await fetchSentVacationCount(); //
await fetchSentVacationCount();
}
}
);
// targetUser fetchSentVacationCount
watch(
() => props.targetUser,
async (newUser) => {
if (newUser && newUser.MEMBERSEQ) {
console.log(`🔄 새로운 대상(${newUser.name})이 선택되었습니다.`);
await fetchSentVacationCount();
}
},
{ deep: true }
);
//
onMounted(async () => {
if (props.isOpen && props.targetUser && props.targetUser.MEMBERSEQ) {
await fetchSentVacationCount();
@ -132,6 +121,7 @@
});
</script>
<style scoped>
/* 모달 본문 */

View File

@ -1,165 +1,186 @@
<template>
<div v-if="isOpen" class="modal-dialog" @click.self="closeModal">
<div class="modal-content modal-scroll">
<h5 class="modal-title">📅 연차 사용 내역</h5>
<button class="close-btn" @click="closeModal"></button>
<div v-if="isOpen" class="modal-dialog" @click.self="closeModal">
<div class="modal-content modal-scroll">
<h5 class="modal-title">📅 연차 사용 내역</h5>
<button class="close-btn" @click="closeModal"></button>
<!-- 연차 사용 받은 연차 리스트 -->
<div class="modal-body" v-if="mergedVacations.length > 0">
<ol class="vacation-list">
<li
v-for="(vacation, index) in mergedVacations"
:key="index"
class="vacation-item"
>
<span v-if="vacation.type === 'used'" class="vacation-index">
{{ totalUsedVacationCount - usedVacations.findIndex(v => v.date === vacation.date) }})
</span>
<span :class="vacation.type === 'used' ? 'minus-symbol' : 'plus-symbol'">
{{ vacation.type === 'used' ? '-' : '+' }}
</span>
<span :style="{ color: userColors[vacation.senderId || vacation.receiverId] || '#000' }"
class="vacation-date">{{ formatDate(vacation.date) }}</span>
</li>
</ol>
</div>
<!-- 연차 사용 받은 연차 리스트 -->
<div class="modal-body" v-if="mergedVacations.length > 0">
<ol class="vacation-list">
<li v-for="(vacation, index) in mergedVacations" :key="index" class="vacation-item">
<span v-if="vacation.type === 'used'" class="vacation-index">
{{ getVacationIndex(index) }})
</span>
<span :class="vacation.type === 'used' ? 'minus-symbol' : 'plus-symbol'">
{{ vacation.type === 'used' ? '-' : '+' }}
</span>
<span
:style="{ color: userColors[vacation.senderId || vacation.receiverId] || '#000' }"
class="vacation-date"
>
{{ formatDate(vacation.date) }}
</span>
</li>
</ol>
</div>
<!-- 연차 데이터 없음 -->
<p v-if="mergedVacations.length === 0" class="no-data">
🚫 사용한 연차가 없습니다.
</p>
<!-- 연차 데이터 없음 -->
<p v-if="mergedVacations.length === 0" class="no-data">
🚫 사용한 연차가 없습니다.
</p>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, computed } from "vue";
const props = defineProps({
isOpen: Boolean,
myVacations: {
type: Array,
default: () => [],
},
receivedVacations: {
type: Array,
default: () => [],
},
userColors: {
type: Object,
default: () => ({}),
},
isOpen: Boolean,
myVacations: {
type: Array,
default: () => [],
},
receivedVacations: {
type: Array,
default: () => [],
},
userColors: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(["close"]);
//
const totalUsedVacationCount = computed(() => props.myVacations.length);
// +
const usedVacations = computed(() => props.myVacations.map(v => ({ ...v, type: "used" })));
const receivedVacations = computed(() => props.receivedVacations.map(v => ({ ...v, type: "received" })));
const mergedVacations = computed(() => {
return [...usedVacations.value, ...receivedVacations.value].sort(
(a, b) => new Date(b.date) - new Date(a.date)
const usedVacations = computed(() =>
props.myVacations.map(v => ({ ...v, type: "used" }))
);
const receivedVacations = computed(() =>
props.receivedVacations.map(v => ({ ...v, type: "received" }))
);
//
const mergedVacations = computed(() => {
return [...usedVacations.value, ...receivedVacations.value].sort(
(a, b) => new Date(b.date) - new Date(a.date)
);
});
// ( )
const getVacationIndex = (index) => {
let count = 0;
for (let i = 0; i <= index; i++) {
const v = mergedVacations.value[i];
count += v.used_quota; //
}
return count;
};
// (YYYY-MM-DD)
const formatDate = (dateString) => {
const date = new Date(dateString);
return date.toISOString().split("T")[0]; // YYYY-MM-DD
const date = new Date(dateString);
return date.toISOString().split("T")[0]; // YYYY-MM-DD
};
const closeModal = () => {
emit("close");
emit("close");
};
</script>
<style scoped>
/* 모달 스타일 */
.modal-dialog {
display: flex;
justify-content: center;
align-items: center;
display: flex;
justify-content: center;
align-items: center;
}
/* 스크롤 가능한 모달 */
.modal-content {
max-height: 60vh;
overflow-y: auto;
padding: 20px;
width: 75%;
background: white;
border-radius: 8px;
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.1);
max-height: 60vh;
overflow-y: auto;
padding: 20px;
width: 75%;
background: white;
border-radius: 8px;
box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.1);
}
/* 닫기 버튼 */
.close-btn {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
font-weight: bold;
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
font-weight: bold;
}
/* 리스트 기본 스타일 */
.vacation-list {
list-style-type: none;
padding-left: 0;
margin-top: 15px;
list-style-type: none;
padding-left: 0;
margin-top: 15px;
}
/* 리스트 아이템 */
.vacation-item {
display: flex;
align-items: center;
font-size: 16px;
font-weight: bold;
margin-bottom: 8px;
padding: 5px 10px;
border-radius: 5px;
background: #f9f9f9;
display: flex;
align-items: center;
font-size: 16px;
font-weight: bold;
margin-bottom: 8px;
padding: 5px 10px;
border-radius: 5px;
background: #f9f9f9;
}
/* 인덱스 (연차 사용 개수) */
.vacation-index {
font-weight: bold;
font-size: 16px;
margin-right: 8px;
color: #333;
font-weight: bold;
font-size: 16px;
margin-right: 8px;
color: #333;
}
/* "-" 빨간색 */
.minus-symbol {
color: red;
font-weight: bold;
margin-right: 8px;
color: red;
font-weight: bold;
margin-right: 8px;
}
/* "+" 파란색 */
.plus-symbol {
color: blue;
font-weight: bold;
margin-right: 8px;
color: blue;
font-weight: bold;
margin-right: 8px;
}
/* 날짜 스타일 */
.vacation-date {
font-size: 16px;
color: #333;
font-size: 16px;
color: #333;
}
/* 연차 유형 스타일 */
.vacation-type {
font-size: 14px;
font-weight: normal;
color: gray;
margin-left: 5px;
}
/* 연차 데이터 없음 */
.no-data {
text-align: center;
font-size: 14px;
color: gray;
margin-top: 10px;
text-align: center;
font-size: 14px;
color: gray;
margin-top: 10px;
}
</style>

View File

@ -66,51 +66,55 @@
const props = defineProps({
currentPage: {
type: Number,
required: true
required: false
},
pages: {
type: Number,
required: true
required: false
},
prePage: {
type: Number,
required: true
required: false
},
nextPage: {
type: Number,
required: true
required: false
},
isFirstPage: {
type: Boolean,
required: true
required: false
},
isLastPage: {
type: Boolean,
required: true
required: false
},
hasPreviousPage: {
type: Boolean,
required: true
required: false
},
hasNextPage: {
type: Boolean,
required: true
required: false
},
navigatePages: {
type: Number,
required: true
required: false
},
navigatepageNums: {
type: Array,
required: true
required: false
},
navigateFirstPage: {
type: Number,
required: true
required: false
},
navigateLastPage: {
type: Number,
required: true
required: false
},
PageData:{
type:Array,
required:false,
}
});

View File

@ -2,32 +2,46 @@
<div class="card mb-6">
<div class="card-body">
<h5 class="card-title mb-1"><div class="list-group-item list-group-item-action d-flex align-items-center cursor-pointer">
<img src="/img/avatars/1.png" class="rounded-circle me-3 w-px-40" >
<img
class="rounded-circle user-avatar border border-3 w-px-40"
:src="`${baseUrl}upload/img/profile/${data.localVote.MEMBERPRF}`"
:style="`border-color: ${data.localVote.usercolor} !important;`"
alt="user"
/>
<div class="w-100">
<div class="d-flex justify-content-between">
<div class="user-info">
<h6 class="mb-1">공공이</h6>
<h6 class="mb-1">{{ data.localVote.MEMBERNAM }}</h6>
</div>
<div class="add-btn">
<!-- 투표완료시 -->
<i class="bx bxs-check-circle link-success"></i>
<!-- 투표작성자만 수정/삭제/종료 가능 -->
<button type="button" class="bx btn btn-danger">종료</button>
<button
v-if="userStore.user.id === data.localVote.LOCVOTREG"
type="button" class="bx btn btn-danger">종료</button>
<EditBtn />
<DeleteBtn />
</div>
</div>
</div>
</div>
</div>
</h5>
<div class="mb-1">회식장소 고릅시다.</div>
<div class="mb-1">24.12.12 11:02 ~ 24.12.12 16:02</div>
<h5>{{ data.localVote.LOCVOTTTL }}</h5>
<div class="mb-1">{{ data.localVote.formatted_LOCVOTRDT }} ~ {{ data.localVote.formatted_LOCVOTEDT }}</div>
<!-- 투표완료시-->
<vote-revote-end />
<button v-if="data.yesVotetotal > 0" class="btn btn-primary btn-sm" >재투표</button>
<!-- 투표안했을시-->
<vote-card-check />
<vote-card-check
v-if="data.yesVotetotal == 0"
@addContents="addContents"
@checkedNames="checkedNames"
:data="data.voteDetails"
:voteInfo="data.localVote"
:total="data.voteDetails.length "/>
<!-- 투표완/미완 인원 -->
<vote-user-list />
<vote-user-list
:data="data.voteMembers"/>
<!-- 투표 결과 -->
<vote-result-list />
</div>
@ -41,8 +55,25 @@ import DeleteBtn from '@c/button/DeleteBtn.vue';
import voteUserList from '@c/voteboard/voteUserList.vue';
import voteResultList from '@c/voteboard/voteResultList.vue';
import voteCardCheck from '@c/voteboard/voteCardCheck.vue';
import voteRevoteEnd from '@c/voteboard/voteRevoteEnd.vue';
import { useUserInfoStore } from '@s/useUserInfoStore';
import $api from '@api';
const props = defineProps({
data: {
type: Object,
required: true,
},
});
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const userStore = useUserInfoStore();
const emit = defineEmits(['addContents','checkedNames']);
const addContents = (itemList, voteId) =>{
emit('addContents',itemList,voteId)
}
const checkedNames = (numList) =>{
emit('checkedNames',numList);
}
</script>
<style scoped>

View File

@ -2,24 +2,96 @@
<div class="card-text">
<div class="demo-inline-spacing mt-4">
<!-- 투표리스트 -->
<vote-card-check-list />
<div class="d-flex align-items-center">
<PlusBtn/>
<FormInput title="추가항목" name="addContent" :isLabel="false" :is-essential="true" :is-alert="titleAlert" @update:data="title = $event" />
<button class="btn btn-primary ms-1">저장</button>
<div v-for="(item, index) in data"
:key="index">
<vote-card-check-list
:data="item"
:multiIs = voteInfo.LOCVOTMUL
:selectedValues="checkedNames"
@update:selectedValues="updateCheckedNames"
/>
<div v-if="voteInfo.LOCVOTADD ==='1' && index === data.length - 1" class="d-flex align-items-center">
<div class="d-flex flex-column gap-2">
<div v-for="(item, index) in itemList" :key="index" class="d-flex align-items-center">
<form-input
class="flex-grow-1 me-2"
:title="'항목 ' + (index + data.length + 1)"
:name="'content' + index"
:is-essential="false"
:is-alert="contentAlerts[index]"
v-model="item.content"
/>
<link-input v-model="item.url" />
<delete-btn @click="removeItem(index)" class="ms-2" />
</div>
<div class="mb-4 d-flex justify-content">
<plus-btn @click="addItem" :disabled="total >= 10" class="mb-3" />
<button class="btn btn-primary btn-icon mb-3" @click="addContentSave(item.LOCVOTSEQ)" :disabled="isSaveDisabled">
<i class="bx bx-check"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<button class="btn btn-primary btn-sm">투표하기</button>
<button class="btn btn-primary btn-sm" @click="selectVote">투표하기</button>
</template>
<script setup>
import $api from '@api';
import PlusBtn from '@c/button/PlusBtn.vue';
import FormInput from '@c/input/FormInput.vue';
import voteCardCheckList from '@c/voteboard/voteCardCheckList.vue';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import LinkInput from "@/components/voteboard/voteLinkInput.vue";
import { voteCommon } from '@s/voteCommon';
import DeleteBtn from "@c/button/DeleteBtn.vue";
import { useToastStore } from '@s/toastStore';
import router from '@/router';
const toastStore = useToastStore();
const contentAlerts = ref(false);
const titleAlert = ref(false);
const title = ref('');
const rink = ref('');
const { itemList, addItem, removeItem } = voteCommon(true);
const total = computed(() => props.total + itemList.value.length);
const isSaveDisabled = computed(() => {
return itemList.value.length === 0 || itemList.value.every(item => !item.content.trim());
});
const props = defineProps({
data: {
type: Array,
required: true,
},
voteInfo: {
type: Object,
required: true,
},
total: {
type: Number,
required: true,
},
});
const emit = defineEmits(['addContents','checkedNames']);
//
const addContentSave = (voteId) =>{
emit('addContents',itemList.value,voteId);
itemList.value = [{ content: "", url: "" }];
}
const checkedNames = ref([]); //
const updateCheckedNames = (newValues) => {
checkedNames.value = newValues;
};
const selectVote = () =>{
emit('checkedNames',checkedNames.value);
}
</script>
<style >

View File

@ -1,14 +1,57 @@
<template>
<div class="list-group">
<label class="list-group-item">
<input class="form-check-input me-1" type="checkbox" value="">
case1
<input
class="form-check-input me-1"
:name="data.LOCVOTSEQ"
:type="multiIs === '1' ? 'checkbox' : 'radio'"
:value="data.VOTDETSEQ"
:checked="selectedValues.includes(data.VOTDETSEQ)"
@change="handleChange"
>
{{ data.LOCVOTCON }}
<a v-if="data.LOCVOTLIK" :href="data.LOCVOTLIK.startsWith('http') ? data.LOCVOTLIK : 'http://' + data.LOCVOTLIK" target="_blank">
{{ data.LOCVOTLIK }}
</a>
</label>
</div>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
data: {
type: Object,
required: true,
},
multiIs: {
type: String,
required: true,
},
selectedValues: {
type: Array,
required: true,
},
})
const emit = defineEmits(["update:selectedValues"]);
const handleChange = (event) => {
const value = event.target.value;
let updatedValues = [];
//
if (props.multiIs === "1") {
updatedValues = event.target.checked
? [...props.selectedValues, { VOTDETSEQ: value, LOCVOTSEQ: props.data.LOCVOTSEQ, LOCVOTCON: props.data.LOCVOTCON }]
: props.selectedValues.filter(v => v.VOTDETSEQ !== value);
} else {
//
updatedValues = [{ VOTDETSEQ: value, LOCVOTSEQ: props.data.LOCVOTSEQ, LOCVOTCON: props.data.LOCVOTCON }];
}
emit("update:selectedValues", updatedValues);
};
</script>
<style>

View File

@ -1,13 +1,33 @@
<template>
<div>
<card />
<card
@addContents="addContents"
@checkedNames="checkedNames"
v-for="(item, index) in data"
:key="index"
:data="item" />
</div>
</template>
<script setup>
import card from '@c/voteboard/voteCard.vue'
const props = defineProps({
data: {
type: Array,
required: true,
},
});
const emit = defineEmits(['addContents','checkedNames']);
const addContents = (itemList ,voteId) =>{
emit('addContents',itemList ,voteId);
}
const checkedNames = (numList) =>{
emit('checkedNames',numList);
}
</script>
<style scoped>
</style>

View File

@ -1,11 +1,10 @@
<template>
<div class="d-flex align-items-center">
<i class='bx bxs-user-check link-info'></i>
<div class="d-flex align-items-center ">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap border-top-0 p-0">
<div class="d-flex flex-wrap align-items-center">
<ul class="list-unstyled users-list d-flex align-items-center avatar-group m-0 me-2">
<vote-complete-user-list-card />
<vote-complete-user-list-card :data="data"/>
</ul>
</div>
</li>
@ -15,7 +14,12 @@
<script setup>
import voteCompleteUserListCard from '@c/voteboard/voteCompleteUserListCard.vue';
const props = defineProps({
data: {
type: Object,
required: true,
},
});
</script>
<style lang="scss" scoped>

View File

@ -1,10 +1,45 @@
<template>
<li data-bs-toggle="tooltip" data-popup="tooltip-custom" data-bs-placement="top" class="avatar pull-up" aria-label="Vinnie Mostowy" data-bs-original-title="Vinnie Mostowy">
<img class="rounded-circle" src="/img/avatars/1.png" alt="Avatar">
<li
data-bs-toggle="tooltip"
data-popup="tooltip-custom"
data-bs-placement="top"
class="avatar pull-up"
:aria-label="data.MEMBERSEQ"
:data-bs-original-title="getTooltipTitle(data)">
<img
class="rounded-circle user-avatar border border-3"
:src="`${baseUrl}upload/img/profile/${data.MEMBERPRF}`"
:style="`border-color: ${data.usercolor} !important;`"
alt="user"
/>
</li>
</template>
<script setup>
import { useUserInfoStore } from '@s/useUserInfoStore';
import $api from '@api';
import { nextTick, onMounted } from 'vue';
const props = defineProps({
data: {
type: Object,
required: true,
},
});
const userStore = useUserInfoStore();
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
onMounted(async () => {
nextTick(() => {
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach((tooltip) => {
new bootstrap.Tooltip(tooltip);
});
});
});
const getTooltipTitle = (user) => {
return user.MEMBERSEQ === userStore.user.id ? '나' : user.MEMBERNAM;
};
</script>

View File

@ -1,11 +1,10 @@
<template>
<div class="d-flex align-items-center">
<i class='bx bxs-user-x link-danger'></i>
<div class="d-flex align-items-center ">
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap border-top-0 p-0">
<div class="d-flex flex-wrap align-items-center">
<ul class="list-unstyled users-list d-flex align-items-center avatar-group m-0 me-2">
<vote-in-complete-user-list-card />
<vote-in-complete-user-list-card :data="data" />
</ul>
</div>
</li>
@ -15,7 +14,12 @@
<script setup>
import voteInCompleteUserListCard from '@c/voteboard/voteInCompleteUserListCard.vue';
const props = defineProps({
data: {
type: Object,
required: true,
},
});
</script>
<style lang="scss" scoped>

View File

@ -1,10 +1,45 @@
<template>
<li data-bs-toggle="tooltip" data-popup="tooltip-custom" data-bs-placement="top" class="avatar pull-up" aria-label="Vinnie Mostowy" data-bs-original-title="Vinnie Mostowy">
<img class="rounded-circle" src="/img/avatars/3.png" alt="Avatar">
<li
data-bs-toggle="tooltip"
data-popup="tooltip-custom"
data-bs-placement="top"
class="avatar pull-up"
:aria-label="data.MEMBERSEQ"
:data-bs-original-title="getTooltipTitle(data)">
<img
class="rounded-circle user-avatar border border-3"
:src="`${baseUrl}upload/img/profile/${data.MEMBERPRF}`"
:style="`border-color: ${data.usercolor} !important;`"
alt="user"
/>
</li>
</template>
<script setup>
import { useUserInfoStore } from '@s/useUserInfoStore';
import $api from '@api';
import { nextTick, onMounted } from 'vue';
const props = defineProps({
data: {
type: Object,
required: true,
},
});
const userStore = useUserInfoStore();
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
onMounted(async () => {
nextTick(() => {
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach((tooltip) => {
new bootstrap.Tooltip(tooltip);
});
});
});
const getTooltipTitle = (user) => {
return user.MEMBERSEQ === userStore.user.id ? '나' : user.MEMBERNAM;
};
</script>

View File

@ -0,0 +1,80 @@
<template>
<div class="position-relative me-2">
<i class="bx bx-link-alt" @click="togglePopover"></i>
<!-- 링크 팝업 -->
<div
v-if="isPopoverVisible"
class="popover bs-popover-auto fade show d-flex align-items-center"
role="tooltip"
:style="popoverStyle"
>
<div class="popover-arrow"></div>
<input
v-model="link"
placeholder="URL을 입력해주세요"
class="form-control me-2 flex-grow-1"
style="min-width: 200px;"
/>
<button type="button" class="btn btn-sm btn-primary ms-2" @click="saveLink">
등록
</button>
</div>
<!-- 등록된 링크 표시 -->
<div v-if="link" class="mt-1">
<a :href="formattedLink" target="_blank" rel="noopener noreferrer">{{ link }}</a>
</div>
</div>
</template>
<script setup>
import { ref, computed } from "vue";
const props = defineProps({
modelValue: String,
});
const emit = defineEmits(["update:modelValue"]);
const isPopoverVisible = ref(false);
const link = ref(props.modelValue || "");
const popoverStyle = ref({});
const formattedLink = computed(() => {
return link.value.startsWith("http") ? link.value : "http://" + link.value;
});
const togglePopover = (event) => {
const buttonRect = event.target.getBoundingClientRect();
const parentRect = event.target.parentElement.getBoundingClientRect();
popoverStyle.value = {
position: "absolute",
top: `${buttonRect.bottom - parentRect.top + 5}px`,
left: `${buttonRect.left - parentRect.left}px`,
zIndex: "1050",
display: "flex",
alignItems: "center",
};
isPopoverVisible.value = !isPopoverVisible.value;
};
const saveLink = () => {
emit("update:modelValue", link.value);
isPopoverVisible.value = false;
};
</script>
<style scoped>
.popover {
max-width: 300px;
border-radius: 6px;
padding: 5px;
}
.popover-arrow {
position: absolute;
top: -5px;
left: 50%;
transform: translateX(-50%);
}
</style>

View File

@ -1,16 +0,0 @@
<template>
<div class="user-status">
<span class="badge badge-dot bg-warning"></span>
<small>소고기 </small>
<button class="btn btn-primary btn-sm">재투표</button>
</div>
</template>
<script setup>
</script>
<style>
</style>

View File

@ -1,17 +1,42 @@
<template>
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<!--투표한 사람 목록 -->
<vote-complete-user-list />
<div class="d-flex align-items-center gap-2">
<i class='bx bxs-user-check link-info'></i>
<vote-complete-user-list
v-for="(item, index) in voetedUsers"
:key="index"
:data="item"
/>
</div>
<!-- 투표안한 사람 목록 -->
<vote-in-complete-user-list />
<div class="d-flex align-items-center gap-2 ms-auto">
<i class='bx bxs-user-x link-danger'></i>
<vote-in-complete-user-list
v-for="(item, index) in noVoetedUsers"
:key="index"
:data="item"/>
</div>
</div>
</template>
<script setup>
import voteCompleteUserList from '@c/voteboard/voteCompleteUserList.vue';
import voteInCompleteUserList from '@c/voteboard/voteInCompleteUserList.vue';
import { computed } from 'vue';
const props = defineProps({
data: {
type: Array,
required: true,
},
});
const voetedUsers = computed(()=>{
return props.data.filter(user => user.voted === 1);
})
const noVoetedUsers = computed(()=>{
return props.data.filter(user => user.voted === 0);
})
</script>
<style scoped>

View File

@ -4,10 +4,12 @@
v-if="isWriteVisible"
@close="isWriteVisible = false"
:dataList="cateList"
@addCategory="addCategory"
@addCategory="addCategory"
@addWord="editWord"
:NumValue="item.WRDDICSEQ"
:formValue="item.WRDDICCAT"
:titleValue="item.WRDDICTTL"
:contentValue="$common.contentToHtml(item.WRDDICCON)"
:titleValue="item.WRDDICTTL"
:contentValue="item.WRDDICCON"
/>
<div v-else>
@ -57,13 +59,16 @@
<script setup>
import axios from "@api";
import { useToastStore } from '@s/toastStore';
import { ref, toRefs } from 'vue';
import { ref, toRefs, getCurrentInstance, } from 'vue';
import EditBtn from '@/components/button/EditBtn.vue';
import $api from '@api';
import DictWrite from './DictWrite.vue';
const toastStore = useToastStore();
const { appContext } = getCurrentInstance();
const $common = appContext.config.globalProperties.$common;
// Props
const props = defineProps({
item: {
@ -121,6 +126,42 @@ const addCategory = (data) => {
}
}
//
const editWord = (data) => {
console.log('📌 수정할 데이터:', data);
console.log('📌 수정할 데이터:', data.id);
console.log('📌 수정할 데이터:', data.category);
console.log('📌 수정할 데이터:', data.title);
console.log('📌 수정할 데이터:', $common.deltaAsJson(data.content));
if (!data.id) {
console.error('❌ 수정할 데이터의 ID가 없습니다.');
toastStore.onToast('수정할 용어의 ID가 필요합니다.', 'e');
return;
}
axios.patch('worddict/updateWord', {
WRDDICSEQ: data.id,
WRDDICCAT: 600104,
WRDDICTTL: data.title,
WRDDICRIK: $common.deltaAsJson(data.content),
})
.then((res) => {
if (res.data.data === '1') {
toastStore.onToast('✅ 용어가 수정되었습니다.', 's');
isWriteVisible.value = false; //
// getwordList(); //
} else {
console.warn('⚠️ 서버 응답이 예상과 다릅니다:', res.data);
toastStore.onToast('용어 수정이 정상적으로 처리되지 않았습니다.', 'e');
}
})
.catch((err) => {
console.error('❌ 용어 수정 중 오류 발생:', err.response?.data || err.message);
toastStore.onToast(`용어 수정 실패: ${err.response?.data?.message || '알 수 없는 오류'}`, 'e');
});
};
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
//

View File

@ -40,8 +40,7 @@
/>
</div>
<div>
<QEditor @update:data="content = $event" @update:imageUrls="imageUrls = $event" :is-alert="wordContentAlert" />
{{ contentValue }}
<QEditor @update:data="content = $event" @update:imageUrls="imageUrls = $event" :is-alert="wordContentAlert" :initialData="contentValue"/>
<div class="text-end mt-5">
<button class="btn btn-primary" @click="saveWord">
<i class="bx bx-check"></i>
@ -79,6 +78,9 @@ const props = defineProps({
type: Array,
default: () => []
},
NumValue : {
type: Number
},
formValue : {
type:[String, Number]
},
@ -129,6 +131,7 @@ const saveWord = () => {
}
const wordData = {
id: props.NumValue || null,
title: wordTitle.value,
category: selectCategory.value,
content: content.value,

26
src/stores/voteCommon.js Normal file
View File

@ -0,0 +1,26 @@
// voteCommon.js
import { ref } from "vue";
export function voteCommon(isVOte= false) {
const itemList = ref(isVOte ? [] : [{ content: "", url: "" }, { content: "", url: "" }]);
const addItem = () => {
if (itemList.value.length < 10) {
itemList.value.push({ content: "", url: "" });
}
};
const removeItem = (index) => {
if (!isVOte && index >= 2) {
itemList.value.splice(index, 1);
} else if (isVOte && itemList.value.length > 0) {
itemList.value.splice(index, 1);
}
};
return {
itemList,
addItem,
removeItem,
};
}

View File

@ -190,6 +190,8 @@ const fetchGeneralPosts = async (page = 1) => {
console.log(data)
const totalPosts = data.data.total; //
console.log('📌 API 응답 데이터:', data.data);
generalList.value = data.data.list.map((post, index) => ({
realId: post.id,
id: totalPosts - ((page - 1) * selectedSize.value) - index,

View File

@ -94,7 +94,11 @@
@updateReaction="handleUpdateReaction"
@submitComment="handleCommentReply"
/>
<Pagination/>
<Pagination
v-if="pagination.pages"
v-bind="pagination"
@update:currentPage="handlePageChange"
/>
</div>
</div>
</div>
@ -103,7 +107,7 @@
</template>
<script setup>
import BoardCommentArea from '@c/board/BoardComentArea.vue';
import BoardCommentArea from '@/components/board/BoardCommentArea.vue';
import BoardProfile from '@c/board/BoardProfile.vue';
import BoardCommentList from '@c/board/BoardCommentList.vue';
import BoardRecommendBtn from '@c/button/BoardRecommendBtn.vue';
@ -141,6 +145,21 @@ const passwordAlert = ref("");
const isPassword = ref(false);
const lastClickedButton = ref("");
const pagination = ref({
currentPage: 1,
pages: 1,
prePage: 0,
nextPage: 1,
isFirstPage: true,
isLastPage: false,
hasPreviousPage: false,
hasNextPage: false,
navigatePages: 10,
navigatepageNums: [1],
navigateFirstPage: 1,
navigateLastPage: 1
});
//
const fetchBoardDetails = async () => {
@ -202,10 +221,13 @@ const handleUpdateReaction = async ({ boardId, commentId, isLike, isDislike }) =
};
//
const fetchComments = async () => {
const fetchComments = async (page = 1) => {
try {
const response = await axios.get(`board/${currentBoardId.value}/comments`, {
params: { LOCBRDSEQ: currentBoardId.value }
params: {
LOCBRDSEQ: currentBoardId.value,
page
}
});
console.log("목록 API 응답 데이터:", response.data);
@ -240,6 +262,22 @@ const fetchComments = async () => {
// console.log(" comments :", comments.value);
pagination.value = {
...pagination.value,
currentPage: response.data.data.pageNum, //
pages: response.data.data.pages, //
prePage: response.data.data.prePage, //
nextPage: response.data.data.nextPage, //
isFirstPage: response.data.data.isFirstPage, //
isLastPage: response.data.data.isLastPage, //
hasPreviousPage: response.data.data.hasPreviousPage, //
hasNextPage: response.data.data.hasNextPage, //
navigatePages: response.data.data.navigatePages, //
navigatepageNums: response.data.data.navigatepageNums, //
navigateFirstPage: response.data.data.navigateFirstPage, //
navigateLastPage: response.data.data.navigateLastPage //
};
} catch (error) {
console.error('댓글 목록 불러오기 오류:', error);
}
@ -372,6 +410,14 @@ const deletePost = async () => {
}
};
//
const handlePageChange = (page) => {
if (page !== pagination.value.currentPage) {
pagination.value.currentPage = page;
fetchComments(page);
}
};
//
const formattedDate = (dateString) => {
if (!dateString) return "날짜 없음";

View File

@ -99,6 +99,7 @@ const handleProfileClick = async (user) => {
if (user.MEMBERSEQ === userStore.user.id) {
//
const response = await axios.get(`vacation/history`);
console.log(response)
if (response.status === 200 && response.data) {
myVacations.value = response.data.data.usedVacations || [];

View File

@ -4,7 +4,6 @@
<div class="mt-8">
<!-- 투표 작성 -->
<router-link to="/voteboard/write"><WriteBtn /></router-link>
<!-- 내가한 투표 보기 -->
<div class="d-flex align-items-center">
<div class="form-check me-3">
@ -14,50 +13,87 @@
<!-- 투표마감/투표중 셀렉트 -->
<FormSelect class="col-3" name="cate" :isLabel="false" title="투표상태" :data="categoryList" @update:data="category = $event" />
</div>
<!-- <QEditor @update:data="content = $event" @update:imageUrls="imageUrls = $event" :is-alert="true" />
<button type="button" class="btn btn-primary ms-1" @click="registerContent"><i class="bx bx-check"></i></button> -->
<!-- 투표리스트 -->
<vote-list />
<vote-list
:data="voteListCardData"
@addContents="addContents"
@checkedNames="checkedNames"/>
</div>
</div>
<!-- <div class="mt-8">
<pagination />
</div> -->
<!-- 페이지네이션 -->
<div class="row g-3">
<div class="mt-8">
<Pagination
v-if="PageData.pages"
v-bind="PageData"
@update:currentPage="handlePageChange"
/>
</div>
</div>
</div>
</template>
<script setup>
import { getCurrentInstance, onMounted, ref } from 'vue';
// import Pagination from '@/components/pagination/Pagination.vue';
import Pagination from '@c/pagination/Pagination.vue';
import router from '@/router';
import FormSelect from '@c/input/FormSelect.vue';
import { useToastStore } from '@s/toastStore';
import QEditor from '@c/editor/QEditor.vue';
import $api from '@api';
import BoardCard from '@c/list/BoardCard.vue';
import Quill from 'quill';
import WriteBtn from '@c/button/WriteBtn.vue';
import voteList from '@c/voteboard/voteCardList.vue';
const toastStore = useToastStore();
const category = ref('0');
const categoryList = ['전체','투표마감', '투표중'];
const boardList = ref([]);
const PageData = ref([]);
const voteListCardData = ref([]);
const titleAlert = ref(false);
const addContent = ref('');
onMounted(()=>{
getBoardList();
})
const getBoardList = () =>{
$api.get('worddict/getWordList').then((res)=>{
boardList.value = res.data.data.data;
})
const currentPage = ref(1);
onMounted(async () => {
getvoteList();
});
//
const getvoteList = async () => {
console.log('pagee',currentPage.value)
const response = await $api.get('vote/getVoteList',{
params: { page: currentPage.value }
});
if (response.data.status === "OK") {
PageData.value = response.data.data;
voteListCardData.value = response.data.data.list;
}
};
//
const addContents = (itemList, voteId) =>{
$api.post('vote/insertWord',{
itemList :itemList
,voteId :voteId
}).then((res)=>{
if(res.data.status === 'OK'){
toastStore.onToast('항목이 등록되었습니다.', 's');
getvoteList();
}
})
}
//
const checkedNames = (numList) =>{
$api.post('vote/insertCheckedNums',{
checkedList :numList
,votenum : numList[0].LOCVOTSEQ
}).then((res)=>{
if(res.data.status === 'OK'){
toastStore.onToast('투표가 완료되었습니다.', 's');
getvoteList();
}
})
}
//
const handlePageChange = async (page) => {
currentPage.value=page;
await getvoteList();
};
</script>
<style></style>

View File

@ -1,39 +1,194 @@
<template>
<div class="container-xxl flex-grow-1 container-p-y">
<div class="card mb-6">
<div class="card-body">
<div class="user-list-container">
<ul class="timeline mb-1">
<li class="timeline-item timeline-item-transparent">
<span class="timeline-point timeline-point-info"></span>
<div class="timeline-event">
<div class="timeline-header mb-2">
<h6 class="mb-0">투표 인원</h6>
</div>
<UserList @user-list-update="handleUserListUpdate" />
<div class="container-xxl flex-grow-1 container-p-y">
<div class="card mb-6">
<div class="card-body">
<div class="user-list-container">
<ul class="timeline mb-1">
<li class="timeline-item timeline-item-transparent">
<span class="timeline-point timeline-point-info"></span>
<div class="timeline-event">
<div class="timeline-header mb-2">
<h6 class="mb-0">투표 인원</h6>
</div>
<UserList @userListInfo="userSet" @user-list-update="handleUserListUpdate" class="mb-3" />
<div v-if="UserListAlert" class="red">2명이상 선택해주세요 </div>
<form-input
title="제목"
name="title"
:is-essential="true"
:is-alert="titleAlert"
v-model="title"
/>
<form-input
title="종료날짜"
name="endDate"
type="date"
:is-essential="true"
:is-alert="endDateAlert"
v-model="endDate"
/>
<!-- 항목 입력 반복 -->
<div v-for="(item, index) in itemList" :key="index" class="d-flex align-items-center mb-2 position-relative">
<form-input
class="flex-grow-1 me-2"
:title="'항목 ' + (index + 1)"
:name="'content' + index"
:is-essential="index < 2"
:is-alert="contentAlerts[index]"
v-model="item.content"
/>
<link-input v-model="item.url" />
<delete-btn @click="removeItem(index)" :disabled="index < 2" class="ms-2" />
</div>
<plus-btn @click="addItem" :disabled="itemList.length >= 10" class="mb-3" />
<div>
<label class="list-group-item">
<input
class="form-check-input me-1"
type="checkbox"
id="addvoteitem"
v-model="addvoteitem"
/>
항목 추가여부
</label>
<label class="list-group-item">
<input
class="form-check-input me-1"
type="checkbox"
id="addvotemulti"
v-model="addvotemulti"
/>
다중투표 허용여부
</label>
</div>
</div>
</li>
</ul>
</div>
</li>
</ul>
</div>
</div>
<div class="mb-4 d-flex justify-content-end">
<button type="button" class="btn btn-info" @click="goList">
<i class="bx bx-left-arrow-alt"></i>
</button>
<button type="button" class="btn btn-primary ms-1" @click="saveValid">
<i class="bx bx-check"></i>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from "vue";
import { ref, toRaw } from "vue";
import UserList from "@c/user/UserList.vue";
import formInput from "@c/input/FormInput.vue";
import { useToastStore } from "@s/toastStore";
import PlusBtn from "@c/button/PlusBtn.vue";
import DeleteBtn from "@c/button/DeleteBtn.vue";
import $api from "@api";
import router from "@/router";
import LinkInput from "@/components/voteboard/voteLinkInput.vue";
import { voteCommon } from '@s/voteCommon';
const activeUsers = ref([]); //
const disabledUsers = ref([]); //
const toastStore = useToastStore();
const activeUserList = ref([]);
const disabledUsers = ref([]);
const titleAlert = ref(false);
const endDateAlert = ref(false);
const contentAlerts = ref([false, false]);
const UserListAlert = ref(false);
const title = ref("");
const endDate = ref("");
const { itemList, addItem, removeItem } = voteCommon();
// UserList
const handleUserListUpdate = ({ activeUsers, disabledUsers }) => {
activeUsers.value = activeUsers;
disabledUsers.value = disabledUsers;
console.log('활성화목록>>',activeUsers)
console.log('비활성목록>>',disabledUsers)
const userListTotal = ref(0);
const addvoteitem = ref(false);
const addvotemulti= ref(false);
const userSet = ({ userList, userTotal }) => {
activeUserList.value = userList;
userListTotal.value = userTotal;
};
const handleUserListUpdate = ({ activeUsers, disabledUsers: updatedDisabledUsers }) => {
activeUserList.value = activeUsers;
disabledUsers.value = updatedDisabledUsers;
userListTotal.value = activeUsers.length;
};
const saveValid = () => {
let valid = true;
if (title.value === '') {
titleAlert.value = true;
valid = false;
} else {
titleAlert.value = false;
}
if (endDate.value === '') {
endDateAlert.value = true;
valid = false;
} else {
endDateAlert.value = false;
}
if (itemList.value[0].content === '') {
contentAlerts.value[0] = true;
valid = false;
} else {
contentAlerts.value[0] = false;
}
if (itemList.value[1].content === '') {
contentAlerts.value[1] = true;
valid = false;
} else {
contentAlerts.value[1] = false;
}
if (activeUserList.value.length < 2) {
UserListAlert.value = true;
valid = false;
} else {
UserListAlert.value = false;
}
if (valid) {
saveVote();
}
};
const saveVote = () => {
console.log('activeUserList',activeUserList.value)
const unwrappedUserList = toRaw(activeUserList.value);
const listId = unwrappedUserList.map(item => ({
id: item.MEMBERSEQ,
}));
$api.post('vote/insertWord',{
addvoteIs :addvoteitem ? '1' :'0'
,votemMltiIs :addvotemulti ? '1' :'0'
,title :title.value
,endDate :endDate.value
,itemList :itemList.value
,activeUserList :listId
}).then((res)=>{
if(res.data.status == 'OK'){
toastStore.onToast('투표가 등록되었습니다.', 's');
goList();
}
})
};
const goList = () => {
router.push('/voteboard');
};
</script>
<style scoped>
.item-input {
max-width: 200px;
}
</style>

View File

@ -176,7 +176,6 @@
}
//
const addWord = (wordData) => {
axios.post('worddict/insertWord',{
WRDDICCAT : wordData.category,
WRDDICTTL : wordData.title,