Compare commits

...

78 Commits

Author SHA1 Message Date
5fb90c7330 .
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-07-14 14:36:10 +09:00
fd1c8c4053 멤버리스트 사진 수정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-07-14 14:30:07 +09:00
90ed8819ad .,,
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-14 11:12:52 +09:00
130c8fced0 ㅇㅇ
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-14 10:59:57 +09:00
3804abfa09 .
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-14 10:54:20 +09:00
1be47c1a58 .
Some checks failed
LocalNet_front/pipeline/head There was a failure building this commit
2025-04-14 10:51:42 +09:00
cb5e274ac1 사원리스트트
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-11 15:31:44 +09:00
3a0b09624b 마이페이지 수정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-11 13:51:36 +09:00
9dfe130500 .
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-11 13:33:38 +09:00
96411af84a .
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-11 11:19:29 +09:00
e12e9b8bc8 . 2025-04-11 11:01:42 +09:00
db06418389 아이콘 변경
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-11 10:44:22 +09:00
549a01d454 알림 없을때 탑바 빨간동그라미 안보이게
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-11 09:54:41 +09:00
3cdba34130 . 2025-04-11 09:48:09 +09:00
d3ba7d446e 유튜브 첨부 형식 추가.
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-11 00:37:41 +09:00
cca27b9583 사원등록 실패시 토글리셋 수정 2025-04-10 23:24:02 +09:00
3d147076ef 퇴근취소시에 탑바 셀렉트박스 바로 안따라옴 2025-04-10 21:12:41 +09:00
e75ca56f7d . 2025-04-10 16:24:20 +09:00
5be05bbab6 .
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-10 15:59:11 +09:00
93b8843dd7 .
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-10 15:50:15 +09:00
2bd64142ac .. 2025-04-10 15:44:30 +09:00
4c5b4481b6 Merge branch '250410_park' 2025-04-10 15:23:17 +09:00
79ce960a3a 사원 등록에서 로그인 세션으로 등록/반려가 아닌 DB의 권한을 가져와서 처리하는 방향으로 수정 2025-04-10 15:23:02 +09:00
888a733f4b Merge branch 'main' into mypage 2025-04-10 15:15:30 +09:00
8361a02dc8 Merge branch 'main' into mypage 2025-04-10 15:15:21 +09:00
11ebea8ccd 권한부여 수정 2025-04-10 15:14:53 +09:00
103f5f3a62 Merge branch 'khj' 2025-04-10 15:11:38 +09:00
14c8fb4108 프로필이미지 꽉차게->USER-AVATAR PADDING없애기 2025-04-10 15:10:17 +09:00
803e6da4b3 유저 승인 api 변경
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-10 13:42:32 +09:00
ba9a752250 게시판 동영상 수정 2025-04-10 13:28:50 +09:00
5c7f7c6346 Merge branch 'khj' 2025-04-10 10:30:01 +09:00
5b24a0254b 코드수정정 2025-04-10 10:29:37 +09:00
52c3bbdf6c 1
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-09 08:53:43 +09:00
a27de5443a 일반 영상 url 도 첨부되도록 수정 2025-04-09 08:31:59 +09:00
1b354d464c 비디오
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-08 21:22:33 +09:00
23525d5ba1 영상 링크 기능
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-08 20:40:25 +09:00
632c421ec1 1 2025-04-08 20:25:31 +09:00
94356aba09 휴가 클릭시 좌측 메뉴에 선택 활성화 되게 2025-04-08 20:23:41 +09:00
yoon
fc6c828624 데이터피커추가 2025-04-08 19:23:58 +09:00
yoon
437592ed0d selectbox 프로젝트 선택 변경 사항 감지 추가 2025-04-08 19:09:37 +09:00
yoon
affc1f4b59 알림 2025-04-08 18:52:49 +09:00
yoon
5667c3edf8 프로젝트 설명 없을때 하이픈 및 삭제 컨펌 2025-04-08 18:45:27 +09:00
yoon
8df2674755 멤버 없을 때 하이픈 2025-04-08 18:44:41 +09:00
f37c8ec947 투표컨펌 추가가 2025-04-08 16:13:07 +09:00
15104c2f44 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-04-08 15:50:00 +09:00
3c54cea558 투표 컨펌펌 2025-04-08 15:49:59 +09:00
4f9a879083 .
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-08 14:37:13 +09:00
70143f3174 . 2025-04-08 14:30:44 +09:00
10068bb1c7 .
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-08 14:28:33 +09:00
8c7b82d0ae d위치수정정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-08 14:19:50 +09:00
028c5bda11 수정 날짜
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-08 13:52:44 +09:00
8321793a31 Merge branch 'khj'
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-08 13:40:22 +09:00
10a955f13f 줄바꿈수정정 2025-04-08 13:39:54 +09:00
f21973705e Merge branch '250408_park'
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-08 11:28:20 +09:00
cc01b95350 좋아요 싫어요 watch 함수 추가 2025-04-08 11:28:07 +09:00
cf88671869 공휴일에는 이벤트 못넣게 수정 2025-04-08 11:03:26 +09:00
b281b38351 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-08 10:57:49 +09:00
3f32c573a7 . 2025-04-08 10:57:47 +09:00
4772077cc1 Merge branch 'khj' 2025-04-08 10:57:30 +09:00
85c06185ca 이미지 클래스 추가가 2025-04-08 10:56:56 +09:00
1d893200cb Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-04-08 10:52:13 +09:00
a7c588986b . 2025-04-08 10:52:12 +09:00
0f1b3fb4d7 Merge branch '250408_park'
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-08 10:47:33 +09:00
c01e45759d 이벤트 타임피커 영역 누를때 시간선택 가능하게 수정 2025-04-08 10:47:13 +09:00
27ab492b45 늘어짐 수정정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-08 10:45:46 +09:00
446f9925c8 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-08 10:39:14 +09:00
a1b304a159 달력 버튼 커서 2025-04-08 10:39:11 +09:00
a6fdcf9013 Merge branch 'khj' 2025-04-08 10:38:35 +09:00
4a8414a1c4 이미지 클래스 추가 / 용어집 영역 설정정 2025-04-08 10:36:47 +09:00
2469d3ec88 날씨 로딩중일떄 글씨 안보이게 2025-04-08 10:33:13 +09:00
6bce7f6e38 프로필 style 수정 2025-04-08 10:30:01 +09:00
683e06424e 탑바 영역 수정 2025-04-08 10:10:46 +09:00
8fec088bfa . 2025-04-08 10:04:35 +09:00
7a37f837d6 날씨 스토리지 2025-04-08 10:01:09 +09:00
9af35ff2d8 Merge branch 'main' into vacation 2025-04-08 09:52:07 +09:00
f18bc15d91 날씨 정보 커스텀 이미지 추가 및 각 라우터 name 설정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-08 01:11:39 +09:00
c43614c743 메인 달력 날씨 업데이트 2025-04-08 00:54:59 +09:00
ffc8b44b46 날씨 스토리지에저장 2025-04-07 16:15:49 +09:00
34 changed files with 1049 additions and 502 deletions

View File

@ -172,6 +172,9 @@
.fc-toolbar-title { .fc-toolbar-title {
cursor: pointer; cursor: pointer;
} }
.fc-today-button {
cursor: pointer !important;
}
/* 클릭 가능한 날짜 */ /* 클릭 가능한 날짜 */
.fc-daygrid-day.clickable { .fc-daygrid-day.clickable {
cursor: pointer; cursor: pointer;
@ -719,6 +722,12 @@
pointer-events: none; pointer-events: none;
} }
.hidden-time-input {
position: absolute;
opacity: 0;
pointer-events: none;
}
/* 권한부여 */ /* 권한부여 */
.user-card-container { .user-card-container {
display: flex; display: flex;
@ -743,7 +752,7 @@
width: 80px; width: 80px;
height: 80px; height: 80px;
border-radius: 50%; border-radius: 50%;
object-fit: contain; object-fit: cover;
margin-bottom: 10px; margin-bottom: 10px;
} }
.switch { .switch {

View File

@ -16,7 +16,7 @@
</template> </template>
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed, watch } from 'vue';
const props = defineProps({ const props = defineProps({
comment: { comment: {
@ -64,6 +64,11 @@
const likeCount = computed(() => props.comment?.likeCount ?? props.likeCount); const likeCount = computed(() => props.comment?.likeCount ?? props.likeCount);
const dislikeCount = computed(() => props.comment?.dislikeCount ?? props.dislikeCount); const dislikeCount = computed(() => props.comment?.dislikeCount ?? props.dislikeCount);
watch([() => props.likeClicked, () => props.dislikeClicked], ([likeNewval, dislikeNewval]) => {
likeClicked.value = likeNewval;
dislikeClicked.value = dislikeNewval;
});
const handleLike = () => { const handleLike = () => {
const isLike = !likeClicked.value; const isLike = !likeClicked.value;
const isDislike = false; const isDislike = false;

View File

@ -4,7 +4,7 @@
<div class="row g-0"> <div class="row g-0">
<div class="col-3 border-end text-center" id="app-calendar-sidebar"> <div class="col-3 border-end text-center" id="app-calendar-sidebar">
<div class="card-body"> <div class="card-body">
<img v-if="user" :src="`${baseUrl}upload/img/profile/${user.profile}`" alt="Profile Image" class="w-px-50 h-px-50 rounded-circle object-fit-contain" @error="$event.target.src = '/img/icons/icon.png'"/> <img v-if="user" :src="`${baseUrl}upload/img/profile/${user.profile}`" alt="Profile Image" class="w-px-50 h-px-50 rounded-circle object-fit-cover" @error="$event.target.src = '/img/icons/icon.png'"/>
<p class="mt-2 fw-bold"> <p class="mt-2 fw-bold">
{{ user.name }} {{ user.name }}
</p> </p>
@ -60,7 +60,7 @@
<div class="row my-2 d-flex align-items-center"> <div class="row my-2 d-flex align-items-center">
<div class="col-4"> <div class="col-4">
<img :src="`${baseUrl}upload/img/profile/${commuter.profile}`" <img :src="`${baseUrl}upload/img/profile/${commuter.profile}`"
class="me-2 w-px-50 h-px-50 rounded-circle object-fit-contain" class="me-2 w-px-50 h-px-50 rounded-circle object-fit-cover"
@error="$event.target.src = '/img/icons/icon.png'"> @error="$event.target.src = '/img/icons/icon.png'">
<span class="fw-bold">{{ commuter.memberName }}</span> <span class="fw-bold">{{ commuter.memberName }}</span>
@ -399,7 +399,7 @@ const loadCommuters = async () => {
// //
const profileImg = document.createElement('img'); const profileImg = document.createElement('img');
profileImg.src = `${baseUrl}upload/img/profile/${commuter.profile}`; profileImg.src = `${baseUrl}upload/img/profile/${commuter.profile}`;
profileImg.className = 'rounded-circle w-px-20 h-px-20 mx-1 mb-1 position-relative z-5 m-auto object-fit-contain'; profileImg.className = 'rounded-circle w-px-20 h-px-20 mx-1 mb-1 position-relative z-5 m-auto object-fit-cover';
profileImg.style.border = `2px solid ${commuter.projctcolor}`; profileImg.style.border = `2px solid ${commuter.projctcolor}`;
profileImg.onerror = () => { profileImg.src = '/img/icons/icon.png'; }; profileImg.onerror = () => { profileImg.src = '/img/icons/icon.png'; };

View File

@ -23,7 +23,7 @@
<img <img
:src="`${baseUrl}upload/img/profile/${commuter.profile}`" :src="`${baseUrl}upload/img/profile/${commuter.profile}`"
alt="User Profile" alt="User Profile"
class="rounded-circle object-fit-contain" class="rounded-circle object-fit-cover"
:class="isCurrentUser(commuter) ? 'cursor-pointer' : ''" :class="isCurrentUser(commuter) ? 'cursor-pointer' : ''"
:draggable="isCurrentUser(commuter)" :draggable="isCurrentUser(commuter)"
@dragstart="isCurrentUser(commuter) ? dragStart($event, post) : null" @dragstart="isCurrentUser(commuter) ? dragStart($event, post) : null"

View File

@ -41,6 +41,7 @@
<button class="ql-link">Link</button> <button class="ql-link">Link</button>
<button class="ql-image">Image</button> <button class="ql-image">Image</button>
<button class="ql-video">Video</button>
<button class="ql-blockquote">Blockquote</button> <button class="ql-blockquote">Blockquote</button>
<button class="ql-code-block">Code Block</button> <button class="ql-code-block">Code Block</button>
</div> </div>
@ -54,8 +55,11 @@
<script setup> <script setup>
import Quill from 'quill'; import Quill from 'quill';
import 'quill/dist/quill.snow.css'; import 'quill/dist/quill.snow.css';
import { onMounted, ref, watch, defineEmits, defineProps } from 'vue';
import $api from '@api'; import $api from '@api';
import { onMounted, ref, watch, defineEmits, defineProps } from 'vue';
import { useToastStore } from '@s/toastStore';
const toastStore = useToastStore();
const props = defineProps({ const props = defineProps({
isAlert: { isAlert: {
@ -131,6 +135,38 @@
initCheckImageIndex(); initCheckImageIndex();
} }
//
quillInstance.getModule('toolbar').addHandler('video', () => {
const url = prompt('YouTube 영상 URL을 입력하세요:');
let src = '';
if (!url || url.trim() == '') return;
// youtube url
if (url.indexOf('watch?v=') !== -1) {
src = url.replace('watch?v=', 'embed/');
// youtu.be URL (ex : https://youtu.be/CfiojceAaeQ?si=G7eM56sdDjIEw-Tz)
} else if (url.indexOf('youtu.be/') !== -1) {
const videoId = url.split('youtu.be/')[1].split('?')[0];
src = `https://www.youtube.com/embed/${videoId}`;
// iframe
} else if (url.indexOf('<iframe') !== -1) {
// DOMParser embeded url
const parser = new DOMParser();
const doc = parser.parseFromString(url, 'text/html');
const iframeEL = doc.querySelector('iframe');
src = iframeEL.getAttribute('src');
} else {
toastStore.onToast('지원하는 영상 타입 아님', 'e');
return;
}
const index = quillInstance.getSelection().index;
quillInstance.insertEmbed(index, 'video', src);
quillInstance.setSelection(index + 1);
});
// //
let imageUrls = new Set(); // URL let imageUrls = new Set(); // URL
quillInstance.getModule('toolbar').addHandler('image', () => { quillInstance.getModule('toolbar').addHandler('image', () => {

View File

@ -14,7 +14,7 @@
:value="computedValue" :value="computedValue"
:disabled="disabled" :disabled="disabled"
:maxLength="maxlength" :maxLength="maxlength"
:placeholder="title" :placeholder="placeholder ? placeholder : title"
@blur="$emit('blur')" @blur="$emit('blur')"
/> />
<span class="input-group-text">@ localhost.co.kr</span> <span class="input-group-text">@ localhost.co.kr</span>
@ -29,7 +29,7 @@
:value="computedValue" :value="computedValue"
:disabled="disabled" :disabled="disabled"
:maxLength="maxlength" :maxLength="maxlength"
:placeholder="title" :placeholder="placeholder ? placeholder : title"
@blur="$emit('blur')" @blur="$emit('blur')"
@click="handleDateClick" @click="handleDateClick"
ref="inputElement" ref="inputElement"
@ -89,6 +89,10 @@
default: false, default: false,
required: false, required: false,
}, },
placeholder: {
type: String,
default: ''
},
}); });
const emits = defineEmits(['update:data', 'update:alert', 'blur']); const emits = defineEmits(['update:data', 'update:alert', 'blur']);

View File

@ -3,7 +3,7 @@
<div class="row g-0"> <div class="row g-0">
<div class="card-body"> <div class="card-body">
<!-- 제목 --> <!-- 제목 -->
<div class="d-flex justify-content-between "> <div class="d-flex justify-content-between">
<h5 class="card-title fw-bold"> <h5 class="card-title fw-bold">
{{ title }} {{ title }}
</h5> </h5>
@ -12,42 +12,62 @@
<DeleteBtn v-if="isProjectCreator" @click.stop="handleDelete" class="ms-1"/> <DeleteBtn v-if="isProjectCreator" @click.stop="handleDelete" class="ms-1"/>
</div> </div>
</div> </div>
<!-- 날짜 --> <!-- 날짜 -->
<div class="d-flex flex-sm-row align-items-center pb-2"> <div class="row align-items-center pb-2">
<i class="bx bx-calendar"></i> <div class="col-3 col-md-2 d-flex align-items-center">
<div class="ms-2">날짜</div> <i class="bx bx-calendar"></i>
<div class="ms-12">{{ strdate }} ~ {{ enddate }}</div> <div class="ms-2">날짜</div>
</div> </div>
<!-- 참여자 --> <div class="col-9 col-md-10">
<div class="d-flex flex-sm-row align-items-center pb-2"> {{ strdate }} ~ {{ enddate }}
<i class="bx bxs-user"></i> </div>
<div class="ms-2">참여자</div> </div>
<UserList ref="userListRef" :projctSeq="projctSeq" :showOnlyActive="true" class="ms-8 mb-0" />
</div> <!-- 참여자 -->
<!-- 설명 --> <div class="row align-items-center pb-2">
<div class="d-flex flex-sm-row align-items-center pb-2"> <div class="col-3 col-md-2 d-flex align-items-center">
<i class="bx bx-detail"></i> <i class="bx bxs-user"></i>
<div class="ms-2">설명</div> <div class="ms-2">참여자</div>
<div class="ms-12">{{ description }}</div> </div>
</div> <div class="col-9 col-md-10">
<!-- 주소 --> <UserList ref="userListRef" :projctSeq="projctSeq" :showOnlyActive="true" class="mb-0" />
<div class="d-flex flex-sm-row align-items-center pb-2"> </div>
<MapPopover </div>
:address="address"
:is-visible="isMapVisible" <!-- 설명 -->
@update-visible="updatePopover" <div class="row align-items-center pb-2">
> <div class="col-3 col-md-2 d-flex align-items-center">
<template #trigger> <i class="bx bx-detail"></i>
<div class="d-flex align-items-center cursor-pointer"> <div class="ms-2">설명</div>
<i class="bx bxs-map"></i> </div>
<div class="ms-2">주소</div> <div class="col-9 col-md-10">
</div> {{ description || '-' }}
</template> </div>
</MapPopover> </div>
<div class="ms-12">
{{ address }} {{ addressdtail }} <!-- 주소 -->
<div class="row align-items-center pb-2">
<div class="col-3 col-md-2 d-flex align-items-center">
<MapPopover
:address="address"
:is-visible="isMapVisible"
@update-visible="updatePopover"
>
<template #trigger>
<div class="d-flex align-items-center cursor-pointer">
<i class="bx bxs-map"></i>
<div class="ms-2">주소</div>
</div>
</template>
</MapPopover>
</div>
<div class="col-9 col-md-10 d-flex justify-content-between align-items-center">
<div>{{ address }} {{ addressdtail }}</div>
<button type="button" class="btn text-white" :style="`background-color: ${projctColor} !important;`" @click.stop="openModal">
<i class='bx bx-child'></i>
</button>
</div> </div>
<button type="button" class="btn ms-auto text-white" :style="`background-color: ${projctColor} !important;`" @click.stop="openModal"><i class='bx bx-child'></i></button>
</div> </div>
</div> </div>
</div> </div>
@ -521,17 +541,19 @@ const handleUpdate = async () => {
// //
const handleDelete = () => { const handleDelete = () => {
$api.patch('project/delete', { if (confirm('프로젝트를 삭제하시겠습니까?')) {
projctSeq: props.projctSeq, $api.patch('project/delete', {
projctCol: props.projctCol, projctSeq: props.projctSeq,
}) projctCol: props.projctCol,
.then(res => { })
if (res.status === 200) { .then(res => {
toastStore.onToast('삭제가 완료되었습니다.', 's'); if (res.status === 200) {
projectStore.getProjectList(); toastStore.onToast('프로젝트가 삭제되었습니다.', 's');
projectStore.getMemberProjects(); projectStore.getProjectList();
} projectStore.getMemberProjects();
}) }
})
}
}; };
// //

View File

@ -33,10 +33,21 @@
<span v-if="noInputAlert" class="invalid-feedback d-block" style="padding-left: 5px">{{ noInputAlert }}</span> <span v-if="noInputAlert" class="invalid-feedback d-block" style="padding-left: 5px">{{ noInputAlert }}</span>
</div> </div>
<div class="mb-2"> <div class="mb-2">
<div @click="focusPicker">
<input
type="time"
class="form-control form-control-sm py-1"
style="height: 0%; font-size: 12px"
v-model="eventTime"
@input="handleChangeInput2"
/>
</div>
<input <input
ref="timeInput"
type="time" type="time"
class="form-control form-control-sm py-1" class="hidden-time-input"
style="height: 25px; font-size: 12px" style="height: 0%; font-size: 12px"
v-model="eventTime" v-model="eventTime"
@input="handleChangeInput2" @input="handleChangeInput2"
/> />
@ -81,6 +92,14 @@
const eventTime = ref(''); const eventTime = ref('');
const noInputAlert = ref(null); const noInputAlert = ref(null);
const noInputAlert2 = ref(null); const noInputAlert2 = ref(null);
const timeInput = ref(null);
const focusPicker = () => {
if (timeInput.value) {
timeInput.value.showPicker(); // ( )
timeInput.value.focus(); //
}
};
const eventTypes = [ const eventTypes = [
{ type: 'birthdayParty', code: '300203', title: '생일파티' }, { type: 'birthdayParty', code: '300203', title: '생일파티' },

View File

@ -14,7 +14,14 @@
{{ user.name }} {{ user.name }}
</p> </p>
<CommuterBtn :userId="user.id" :checkedInProject="checkedInProject || {}" ref="workTimeComponentRef" /> <CommuterBtn
ref="workTimeComponentRef"
:userId="user.id"
:checkedInProject="checkedInProject || {}"
:pendingProjectChange="pendingProjectChange"
@update:pendingProjectChange="pendingProjectChange = $event"
@leaveTimeUpdated="handleLeaveTimeUpdate"
/>
<MainEventList <MainEventList
:categoryList="categoryList" :categoryList="categoryList"
@ -41,6 +48,7 @@
class="flatpickr-calendar-only" class="flatpickr-calendar-only"
> >
</full-calendar> </full-calendar>
<input ref="calendarDatepicker" type="text" class="d-none" />
</div> </div>
</div> </div>
</div> </div>
@ -61,12 +69,13 @@
</template> </template>
<script setup> <script setup>
import { inject, onMounted, reactive, ref, watch } from 'vue'; import { inject, onMounted, reactive, ref, watch, nextTick } from 'vue';
import { fetchHolidays } from '@c/calendar/holiday'; import { fetchHolidays } from '@c/calendar/holiday';
import { useUserInfoStore } from '@/stores/useUserInfoStore'; import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useProjectStore } from '@/stores/useProjectStore'; import { useProjectStore } from '@/stores/useProjectStore';
import { useToastStore } from '@s/toastStore'; import { useToastStore } from '@s/toastStore';
import { useWeatherStore } from '@/stores/useWeatherStore'; import { useWeatherStore } from '@/stores/useWeatherStore';
import { useDatePicker } from '@/stores/useDatePicker';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import router from '@/router'; import router from '@/router';
import FullCalendar from '@fullcalendar/vue3'; import FullCalendar from '@fullcalendar/vue3';
@ -85,16 +94,19 @@
const userStore = useUserInfoStore(); const userStore = useUserInfoStore();
const projectStore = useProjectStore(); const projectStore = useProjectStore();
const weatherStore = useWeatherStore(); const weatherStore = useWeatherStore();
const datePickerStore = useDatePicker();
const { dailyWeatherList } = storeToRefs(weatherStore); const { dailyWeatherList } = storeToRefs(weatherStore);
const dayjs = inject('dayjs'); const dayjs = inject('dayjs');
const fullCalendarRef = ref(null); const fullCalendarRef = ref(null);
const workTimeComponentRef = ref(null); const workTimeComponentRef = ref(null);
const calendarEvents = ref([]); const calendarEvents = ref([]);
const calendarDatepicker = ref(null);
//const dailyWeatherList = ref([]); //const dailyWeatherList = ref([]);
const selectedProject = ref(null); const selectedProject = ref(null);
const checkedInProject = ref(null); const checkedInProject = ref(null);
const pendingProjectChange = ref(null);
// //
const showModal = ref(false); const showModal = ref(false);
@ -344,7 +356,7 @@
useFilterEventList(month, day); useFilterEventList(month, day);
}; };
// //
const colorToday = e => { const colorToday = e => {
if (todayEL != null && !todayEL.classList.contains('fc-day-today')) todayEL.classList.add('fc-day-today'); if (todayEL != null && !todayEL.classList.contains('fc-day-today')) todayEL.classList.add('fc-day-today');
}; };
@ -506,10 +518,13 @@
selectAllow: selectInfo => isSelectableDate(selectInfo.start), selectAllow: selectInfo => isSelectableDate(selectInfo.start),
dateClick: handleDateClick, dateClick: handleDateClick,
dayCellDidMount: arg => { dayCellDidMount: arg => {
//
addWeatherInfo(arg);
const dateCell = arg.el; const dateCell = arg.el;
// //
dateCell.addEventListener('mousedown', e => { dateCell.addEventListener('mousedown', e => {
if (!isSelectableDate(arg.date)) return; //
const date = $common.dateFormatter(arg.date, 'YMD'); const date = $common.dateFormatter(arg.date, 'YMD');
handleMouseDown(date, e); handleMouseDown(date, e);
}); });
@ -536,12 +551,51 @@
}, },
}); });
//
const addWeatherInfo = arg => {
const dateStr = $common.dateFormatter(arg.date, 'YMD');
//
const theDayWeatherInfo = dailyWeatherList.value.find(weather => weather.date === dateStr);
const dayTopEl = arg.el.querySelector('.fc-daygrid-day-top');
const isWeatherInfoExist = dayTopEl.getElementsByClassName('weather-icon').length > 0; //
if (theDayWeatherInfo && !isWeatherInfoExist) {
let weatherIconUrl = `https://openweathermap.org/img/wn/${theDayWeatherInfo.icon}.png`;
if (theDayWeatherInfo.icon === '01d' || theDayWeatherInfo.icon === '01n') {
weatherIconUrl = '/img/icons/sunny-custom.png';
}
//
const weatherEl = document.createElement('img');
weatherEl.src = weatherIconUrl;
weatherEl.alt = theDayWeatherInfo.description;
weatherEl.className = 'weather-icon';
weatherEl.style.width = '28px';
weatherEl.style.height = '28px';
//
dayTopEl.classList.add('align-items-center');
dayTopEl.prepend(weatherEl); // reverse
}
};
//
watch(dailyWeatherList, async () => {
await nextTick(); // DOM
document.querySelectorAll('.fc-daygrid-day').forEach(dayCell => {
addWeatherInfo({
el: dayCell,
date: dayCell.dataset.date,
});
});
});
const handleWheelEvent = e => { const handleWheelEvent = e => {
handleCloseModal(); handleCloseModal();
}; };
//
const handleClickVacation = () => { const handleClickVacation = () => {
router.push({ path: 'Vacation' }); router.push('/vacation');
}; };
// ( ) // ( )
@ -552,6 +606,55 @@
}, },
); );
// selectbox
watch(
() => projectStore.selectedProject,
newProject => {
if (newProject) {
selectedProject.value = newProject.PROJCTSEQ;
checkedInProject.value = newProject;
} else {
selectedProject.value = null;
checkedInProject.value = null;
}
},
);
const handleLeaveTimeUpdate = async event => {
const memberSeq = user.value.id;
if (!memberSeq) return;
//
const { data } = await $api.post('main/getUserLeaveRecord', {
memberSeq: memberSeq,
});
const res = data?.data;
if (res && !res?.COMMUTLVE) {
await projectStore.getMemberProjects();
if (projectStore.activeMemberProjectList.length > 0) {
const previousProject =
projectStore.activeMemberProjectList.find(p => res.MEMBERSEQ === user.value.id && res.PROJCTLVE === p.PROJCTSEQ) ||
projectStore.activeMemberProjectList[0]; //
if (previousProject) {
selectedProject.value = previousProject.PROJCTSEQ;
projectStore.setSelectedProject(previousProject);
} else if (projectStore.activeProjectList.length > 0) {
selectedProject.value = projectStore.activeProjectList[0].PROJCTSEQ;
projectStore.setSelectedProject(projectStore.activeProjectList[0]);
} else {
selectedProject.value = null;
projectStore.setSelectedProject(null);
}
} else {
selectedProject.value = null;
projectStore.setSelectedProject(null);
}
}
};
onMounted(async () => { onMounted(async () => {
await userStore.userInfo(); await userStore.userInfo();
user.value = userStore.user; user.value = userStore.user;
@ -570,12 +673,6 @@
param.append('month', month); param.append('month', month);
param.append('day', day); param.append('day', day);
// if (!weatherStore.dailyWeatherList?.length) {
// await weatherStore.getWeatherInfo();
// //dailyWeatherList.value = weatherStore.dailyWeatherList;
// console.log('dailyWeatherList.value: ', dailyWeatherList.value);
// }
// //
await fetchCategoryList(); await fetchCategoryList();
await fetchEventList(param); await fetchEventList(param);
@ -584,6 +681,11 @@
// //
window.addEventListener('wheel', handleWheelEvent); window.addEventListener('wheel', handleWheelEvent);
window.addEventListener('click', colorToday); window.addEventListener('click', colorToday);
datePickerStore.initDatePicker(fullCalendarRef, async (year, month, options) => {
//
await fetchData();
});
}); });
</script> </script>
<style scoped> <style scoped>

View File

@ -25,19 +25,17 @@
<div class="avatar flex-shrink-0 me-1"> <div class="avatar flex-shrink-0 me-1">
<img <img
style="cursor: auto;" style="cursor: auto;"
class="rounded-circle user-avatar" class="rounded-circle user-avatar object-fit-cover"
:src="getProfileImage(item.localVote.MEMBERPRF)" :src="getProfileImage(item.localVote.MEMBERPRF)"
alt="최초 작성자" alt="최초 작성자"
:style="{ borderColor: item.localVote.usercolor }" :style="{ borderColor: item.localVote.usercolor }"
@error="setDefaultImage" @error="setDefaultImage"
/> />
</div> </div>
<!-- <div class="timeline-event ps-1" style="cursor: pointer;" @click="goVoteList()" > -->
<div class="timeline-event ps-1" style="cursor: pointer;" @click.stop="openModal(item.localVote.LOCVOTSEQ)" > <div class="timeline-event ps-1" style="cursor: pointer;" @click.stop="openModal(item.localVote.LOCVOTSEQ)" >
<div class="timeline-header "> <div class="timeline-header ">
<small ><strong>{{ truncateTitle(item.localVote.LOCVOTTTL) }}</strong></small> <small ><strong>{{ truncateTitle(item.localVote.LOCVOTTTL) }}</strong></small>
</div> </div>
<small class="d-flex align-items-center lh-1 me-4 mb-4 mb-sm-0" <small class="d-flex align-items-center lh-1 me-4 mb-4 mb-sm-0"
:style="{ color: getDaysAgo(item.localVote.formatted_LOCVOTEDT) == '금일 종료' ? 'red' : '' }"> :style="{ color: getDaysAgo(item.localVote.formatted_LOCVOTEDT) == '금일 종료' ? 'red' : '' }">
{{getDaysAgo(item.localVote.formatted_LOCVOTEDT)}}({{item.localVote.total_voted}}/{{ item.localVote.total_votable }}) {{getDaysAgo(item.localVote.formatted_LOCVOTEDT)}}({{item.localVote.total_voted}}/{{ item.localVote.total_votable }})
@ -53,7 +51,6 @@
<div class="card-body" v-else> <div class="card-body" v-else>
진행중인 투표가 없습니다. 진행중인 투표가 없습니다.
</div> </div>
</div> </div>
</div> </div>
<!--투표 모달 --> <!--투표 모달 -->
@ -184,30 +181,34 @@ const addContents = (itemList, voteId) => {
} }
// //
const endVoteId = (endVoteId) => { const endVoteId = (endVoteId) => {
$api.patch('vote/updateEndData',{ if(confirm('투표를 종료하시겠습니까?')){
endVoteId :endVoteId $api.patch('vote/updateEndData',{
}).then((res)=>{ endVoteId :endVoteId
if(res.data.status === 'OK'){ }).then((res)=>{
getvoteList(); if(res.data.status === 'OK'){
isModalOpen.value = false; getvoteList();
} isModalOpen.value = false;
}) }
})
}
} }
// //
const voteDelete =(id) =>{ const voteDelete =(id) =>{
$api.patch('vote/updateDeleteData',{ if(confirm('투표를 삭제하시겠습니까?')){
deleteVoteId :id $api.patch('vote/updateDeleteData',{
}).then((res)=>{ deleteVoteId :id
if(res.data.status === 'OK'){ }).then((res)=>{
toastStore.onToast('투표가 삭제되었습니다.', 's'); if(res.data.status === 'OK'){
getvoteList(); toastStore.onToast('투표가 삭제되었습니다.', 's');
isModalOpen.value = false; getvoteList();
} isModalOpen.value = false;
}) }
})
}
} }
// 14 ... // 14 ...
const truncateTitle = title => { const truncateTitle = title => {
return title.length > 10 ? title.slice(0, 10) + '...' : title; return title.length > 10 ? title.slice(0, 10) + '...' : title;
}; };
// //
@ -225,13 +226,11 @@ const goVoteList = () =>{
const getDaysAgo = (dateString) => { const getDaysAgo = (dateString) => {
const inputDate = new Date(dateString); // Date const inputDate = new Date(dateString); // Date
const today = new Date(); // const today = new Date(); //
const input = new Date(inputDate.getFullYear(), inputDate.getMonth(), inputDate.getDate()); const input = new Date(inputDate.getFullYear(), inputDate.getMonth(), inputDate.getDate());
const now = new Date(today.getFullYear(), today.getMonth(), today.getDate()); const now = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const timeDiff = now - input; const timeDiff = now - input;
const dayDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24)); const dayDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
// "" //
if (dayDiff === 0) return "금일 종료"; if (dayDiff === 0) return "금일 종료";
return `종료 ${Math.abs(dayDiff)}일 전`; return `종료 ${Math.abs(dayDiff)}일 전`;
@ -241,6 +240,5 @@ const getDaysAgo = (dateString) => {
<style scoped> <style scoped>
.user-avatar { .user-avatar {
border: 3px solid; border: 3px solid;
padding: 0.1px;
} }
</style> </style>

View File

@ -21,7 +21,7 @@
<!-- 프로필 이미지 --> <!-- 프로필 이미지 -->
<div class="avatar flex-shrink-0 me-2 d-flex align-items-center"> <div class="avatar flex-shrink-0 me-2 d-flex align-items-center">
<img <img
class="rounded-circle user-avatar" class="rounded-circle user-avatar object-fit-cover"
:src="getProfileImage(item.lastEditor.profileImage)" :src="getProfileImage(item.lastEditor.profileImage)"
alt="최종 작성자" alt="최종 작성자"
:style="{ borderColor: item.lastEditor.color }" :style="{ borderColor: item.lastEditor.color }"
@ -135,6 +135,5 @@ return title.length > 25 ? title.slice(0, 25) + '...' : title;
<style scoped> <style scoped>
.user-avatar { .user-avatar {
border: 3px solid; border: 3px solid;
padding: 0.1px;
} }
</style> </style>

View File

@ -50,14 +50,15 @@
<label class="switch" <label class="switch"
><input ><input
type="checkbox" type="checkbox"
:checked="checked" :checked="member.checked"
@change="handleRegisterMember(member.MEMBERSEQ)" /><span class="slider round"></span @click="handleRegisterMember($event, member)" />
<span class="slider round"></span
></label> ></label>
</div> </div>
<button <button
class="btn-close btn-close-sm" class="btn-close btn-close-sm"
style="position: absolute; top: 10px; right: 10px" style="position: absolute; top: 10px; right: 10px"
@click="handleRejectMember(member.MEMBERSEQ)" @click="handleRejectMember(member)"
></button> ></button>
</div> </div>
</div> </div>
@ -76,30 +77,37 @@
import $api from '@api'; import $api from '@api';
const memberList = ref([]); const memberList = ref([]);
const checked = ref(false);
const toast = useToastStore(); const toast = useToastStore();
const imgURL = import.meta.env.VITE_SERVER_IMG_URL; const imgURL = import.meta.env.VITE_SERVER_IMG_URL;
// api // api
const fetchRegisterMemberList = async () => { const fetchRegisterMemberList = async () => {
const { data } = await $api.get('main/registerMemberList'); const { data } = await $api.get('main/registerMemberList');
if (data?.data) memberList.value = data.data; if (data?.data) {
memberList.value = data.data.map(member => ({
...member,
checked: false, // checked
}));
}
}; };
// api // api
const handleRegisterMember = async memberSeq => { const handleRegisterMember = async (e, member) => {
const { data } = await $api.post('main/registerMember', { memberSeq: memberSeq }); e.preventDefault();
const { data } = await $api.post('main/registerMember', { memberSeq: member.MEMBERSEQ });
if (data?.data) { if (data?.data) {
member.checked = true;
toast.onToast(data.data, 's'); toast.onToast(data.data, 's');
fetchRegisterMemberList(); fetchRegisterMemberList();
} }
}; };
// api // api
const handleRejectMember = async memberSeq => { const handleRejectMember = async member => {
if (!confirm('해당 사원 등록을 거절하시겠습니까?')) return; if (!confirm('해당 사원 등록을 거절하시겠습니까?')) return;
const { data } = await $api.post('main/rejectMember', { memberSeq: memberSeq }); const { data } = await $api.post('main/rejectMember', { memberSeq: member.MEMBERSEQ });
if (data?.data) { if (data?.data) {
toast.onToast(data.data, 's'); toast.onToast(data.data, 's');
fetchRegisterMemberList(); fetchRegisterMemberList();

View File

@ -3,9 +3,9 @@
<div class="text-center"> <div class="text-center">
<label <label
for="profilePic" for="profilePic"
class="rounded-circle m-auto ui-bg-cover position-relative cursor-pointer" class="rounded-circle m-auto ui-bg-cover position-relative cursor-pointer "
id="profileLabel" id="profileLabel"
style="width: 100px; height: 100px; background-image: url(img/avatars/default-Profile.jpg); background-repeat: no-repeat" style="width: 100px; height: 100px; background-image: url(img/avatars/default-Profile.jpg); background-repeat: no-repeat; background-size: cover;"
> >
</label> </label>
@ -25,6 +25,7 @@
@update:alert="idAlert = $event" @update:alert="idAlert = $event"
@blur="checkIdDuplicate" @blur="checkIdDuplicate"
:value="id" :value="id"
@keypress="noSpace"
/> />
<span v-if="idError" class="invalid-feedback d-block">{{ idError }}</span> <span v-if="idError" class="invalid-feedback d-block">{{ idError }}</span>
@ -37,6 +38,7 @@
@update:data="password = $event" @update:data="password = $event"
@update:alert="passwordAlert = $event" @update:alert="passwordAlert = $event"
:value="password" :value="password"
@keypress="noSpace"
/> />
<span v-if="passwordError" class="invalid-feedback d-block">{{ passwordError }}</span> <span v-if="passwordError" class="invalid-feedback d-block">{{ passwordError }}</span>
@ -49,6 +51,7 @@
@update:data="passwordcheck = $event" @update:data="passwordcheck = $event"
@update:alert="passwordcheckAlert = $event" @update:alert="passwordcheckAlert = $event"
:value="passwordcheck" :value="passwordcheck"
@keypress="noSpace"
/> />
<span v-if="passwordcheckError" class="invalid-feedback d-block">{{ passwordcheckError }}</span> <span v-if="passwordcheckError" class="invalid-feedback d-block">{{ passwordcheckError }}</span>
@ -82,6 +85,7 @@
@update:data="name = $event" @update:data="name = $event"
@update:alert="nameAlert = $event" @update:alert="nameAlert = $event"
:value="name" :value="name"
@keypress="noSpace"
class="me-2 w-50" class="me-2 w-50"
/> />
@ -214,6 +218,10 @@
const toastStore = useToastStore(); const toastStore = useToastStore();
const noSpace = (e) => {
if (e.key === ' ') e.preventDefault();
};
// //
const profileValid = (size, type) => { const profileValid = (size, type) => {
const maxSize = 5 * 1024 * 1024; const maxSize = 5 * 1024 * 1024;
@ -344,6 +352,7 @@
}); });
watch(password, (newValue) => { watch(password, (newValue) => {
if (newValue && newValue.length >= 4) { if (newValue && newValue.length >= 4) {
passwordErrorAlert.value = false; passwordErrorAlert.value = false;
passwordError.value = ''; passwordError.value = '';
@ -396,8 +405,10 @@
} else { } else {
passwordError.value = ''; passwordError.value = '';
} }
const phoneRegex = /^010\d{8}$/;
const isFormatValid = phoneRegex.test(phone.value);
if (!/^\d+$/.test(phone.value)) { if (!/^\d+$/.test(phone.value) || !isFormatValid) {
phoneAlert.value = true; phoneAlert.value = true;
} else { } else {
phoneAlert.value = false; phoneAlert.value = false;
@ -434,13 +445,13 @@
} }
const formData = new FormData(); const formData = new FormData();
formData.append('memberIds', id.value); formData.append('memberIds', id.value.trim());
formData.append('memberPwd', password.value); formData.append('memberPwd', password.value.trim());
formData.append('memberPwh', pwhint.value); formData.append('memberPwh', pwhint.value);
formData.append('memberPwr', pwhintRes.value); formData.append('memberPwr', pwhintRes.value.trim());
formData.append('memberNam', name.value); formData.append('memberNam', name.value.trim());
formData.append('memberArr', address.value); formData.append('memberArr', address.value);
formData.append('memberDtl', detailAddress.value); formData.append('memberDtl', detailAddress.value.trim());
formData.append('memberZip', postcode.value); formData.append('memberZip', postcode.value);
formData.append('memberBth', birth.value); formData.append('memberBth', birth.value);
formData.append('memberTel', phone.value); formData.append('memberTel', phone.value);

View File

@ -1,9 +1,9 @@
<template> <template>
<ul class="list-unstyled users-list d-flex align-items-center gap-1 flex-wrap"> <ul v-if="displayedUserList && displayedUserList.length > 0" class="list-unstyled users-list d-flex align-items-center gap-1 flex-wrap">
<li <li
v-for="(user, index) in displayedUserList" v-for="(user, index) in displayedUserList"
:key="index" :key="index"
class="avatar pull-up" class="avatar pull-up "
:class="{ 'opacity-100': isUserDisabled(user) }" :class="{ 'opacity-100': isUserDisabled(user) }"
@click.stop="showOnlyActive ? null : toggleDisable(index)" @click.stop="showOnlyActive ? null : toggleDisable(index)"
:style="showOnlyActive ? 'cursor: default' : ''" :style="showOnlyActive ? 'cursor: default' : ''"
@ -14,7 +14,7 @@
:data-bs-original-title="getTooltipTitle(user)" :data-bs-original-title="getTooltipTitle(user)"
> >
<img <img
class="user-avatar border border-3 rounded-circle object-fit-contain" class="user-avatar border border-3 rounded-circle object-fit-cover"
:class="{ 'grayscaleImg': isUserDisabled(user) }" :class="{ 'grayscaleImg': isUserDisabled(user) }"
:src="`${baseUrl}upload/img/profile/${user.MEMBERPRF}`" :src="`${baseUrl}upload/img/profile/${user.MEMBERPRF}`"
:style="`border-color: ${user.usercolor} !important;`" :style="`border-color: ${user.usercolor} !important;`"
@ -23,12 +23,12 @@
/> />
</li> </li>
</ul> </ul>
<span v-else >-</span>
</template> </template>
<script setup> <script setup>
import { onMounted, ref, nextTick, computed, watch } from 'vue'; import { onMounted, ref, nextTick, computed, watch } from 'vue';
import { useUserStore } from '@s/userList'; import { useUserStore } from '@s/userList';
import { useProjectStore } from '@s/useProjectStore';
import $api from '@api'; import $api from '@api';
import { useToastStore } from "@s/toastStore"; import { useToastStore } from "@s/toastStore";

View File

@ -17,7 +17,7 @@
class="start-50 translate-middle crown-icon" class="start-50 translate-middle crown-icon"
/> />
<img <img
class="rounded-circle profile-img" class="rounded-circle object-fit-cover"
:src="getUserProfileImage(user.MEMBERPRF)" :src="getUserProfileImage(user.MEMBERPRF)"
alt="user" alt="user"
:style="getDynamicStyle(user)" :style="getDynamicStyle(user)"

View File

@ -4,7 +4,7 @@
<h5 class="card-title mb-1"> <h5 class="card-title mb-1">
<div class="list-unstyled users-list d-flex align-items-center gap-1"> <div class="list-unstyled users-list d-flex align-items-center gap-1">
<img <img
class="rounded-circle user-avatar border border-3 w-px-40 h-px-40" class="object-fit-cover rounded-circle user-avatar border border-3 w-px-40 h-px-40"
:src="`${baseUrl}upload/img/profile/${data.localVote.MEMBERPRF}`" :src="`${baseUrl}upload/img/profile/${data.localVote.MEMBERPRF}`"
:style="`border-color: ${data.localVote.usercolor} !important;`" :style="`border-color: ${data.localVote.usercolor} !important;`"
@error="$event.target.src = '/img/icons/icon.png'" @error="$event.target.src = '/img/icons/icon.png'"

View File

@ -7,7 +7,7 @@
:aria-label="data.MEMBERSEQ" :aria-label="data.MEMBERSEQ"
:data-bs-original-title="getTooltipTitle(data)"> :data-bs-original-title="getTooltipTitle(data)">
<img <img
class="rounded-circle user-avatar border border-3" class="rounded-circle user-avatar border border-3 object-fit-cover"
:src="`${baseUrl}upload/img/profile/${data.MEMBERPRF}`" :src="`${baseUrl}upload/img/profile/${data.MEMBERPRF}`"
:style="`border-color: ${data.usercolor} !important; width: 90%; height: 90%;`" :style="`border-color: ${data.usercolor} !important; width: 90%; height: 90%;`"
@error="$event.target.src = '/img/icons/icon.png'" @error="$event.target.src = '/img/icons/icon.png'"

View File

@ -7,7 +7,7 @@
:aria-label="data.MEMBERSEQ" :aria-label="data.MEMBERSEQ"
:data-bs-original-title="getTooltipTitle(data)"> :data-bs-original-title="getTooltipTitle(data)">
<img <img
class="rounded-circle user-avatar border border-3" class="rounded-circle user-avatar border border-3 object-fit-cover"
:src="`${baseUrl}upload/img/profile/${data.MEMBERPRF}`" :src="`${baseUrl}upload/img/profile/${data.MEMBERPRF}`"
:style="`border-color: ${data.usercolor} !important; width: 90%; height: 90%;`" :style="`border-color: ${data.usercolor} !important; width: 90%; height: 90%;`"
@error="$event.target.src = '/img/icons/icon.png'" @error="$event.target.src = '/img/icons/icon.png'"

View File

@ -32,7 +32,7 @@
<div class="d-flex flex-wrap align-items-center me-4"> <div class="d-flex flex-wrap align-items-center me-4">
<div class="avatar me-2"> <div class="avatar me-2">
<img <img
class="rounded-circle user-avatar" class="rounded-circle user-avatar object-fit-cover"
:src="getProfileImage(item.author.profileImage)" :src="getProfileImage(item.author.profileImage)"
alt="최초 작성자" alt="최초 작성자"
:style="{ borderColor: item.author.color }" :style="{ borderColor: item.author.color }"
@ -51,7 +51,7 @@
> >
<div class="avatar me-2"> <div class="avatar me-2">
<img <img
class="rounded-circle user-avatar" class="rounded-circle user-avatar object-fit-cover"
:src="getProfileImage(item.lastEditor.profileImage)" :src="getProfileImage(item.lastEditor.profileImage)"
alt="최근 작성자" alt="최근 작성자"
:style="{ borderColor: item.lastEditor.color }" :style="{ borderColor: item.lastEditor.color }"
@ -164,7 +164,6 @@ const toggleEdit = async () => {
.user-avatar { .user-avatar {
border: 3px solid; border: 3px solid;
padding: 0.1px;
} }
.edit-btn { .edit-btn {
@ -182,4 +181,18 @@ const toggleEdit = async () => {
.btn.btn-primary { .btn.btn-primary {
writing-mode: horizontal-tb; writing-mode: horizontal-tb;
} }
.dict-content-wrap {
max-width: 100%;
overflow-x: auto;
word-break: break-word;
word-wrap: break-word;
white-space: normal;
box-sizing: border-box;
}
.dict-content-wrap * {
max-width: 100% !important;
box-sizing: border-box !important;
word-break: break-word;
white-space: normal !important;
}
</style> </style>

View File

@ -49,7 +49,7 @@
@keyup="ValidHandler('title')" @keyup="ValidHandler('title')"
/> />
<div> <div>
<QEditor class="q-editor-container" @keyup="ValidHandler('content')" @update:data="handleContentUpdate" @update:imageUrls="imageUrls = $event" :is-alert="wordContentAlert" :initialData="contentValue"/> <QEditor class="q-editor-container" @keyup="ValidHandler('content')" @update:data="handleContentUpdate" @update:imageUrls="imageUrls = $event" :is-alert="wordContentAlert" :initialData="contentValue"/>
<div class="text-end mt-5"> <div class="text-end mt-5">
<button class="btn btn-primary" @click="saveWord" :disabled="titleValue ? !changed : false"> <button class="btn btn-primary" @click="saveWord" :disabled="titleValue ? !changed : false">
<i class="bx bx-check"></i> <i class="bx bx-check"></i>
@ -233,10 +233,10 @@ const handleCategoryFocusout = (value) => {
</script> </script>
<style> <style>
.q-editor-container { .q-editor-container * {
max-width: 100%; /* 영역이 넘치지 않게 */ max-width: 100% !important;
overflow: auto; /* 넘치는 내용은 스크롤로 처리 */ word-break: break-all !important;
word-wrap: break-word; /* 긴 단어는 자동으로 줄바꿈 */ box-sizing: border-box;
white-space: normal; /* 내용이 길어지면 자동으로 줄바꿈 */ white-space: normal !important;
} }
</style> </style>

View File

@ -80,12 +80,12 @@
<div class="text-truncate">Authorization</div> <div class="text-truncate">Authorization</div>
</RouterLink> </RouterLink>
</li> </li>
<!-- <li class="menu-item" :class="$route.path.includes('/sample') ? 'active' : ''"> <li class="menu-item" :class="$route.path.includes('/people') ? 'active' : ''">
<RouterLink class="menu-link" to="/sample"> <i class="bi "></i> <RouterLink class="menu-link" to="/people"> <i class="bi "></i>
<i class="menu-icon tf-icons bx bx-calendar"></i> <i class="menu-icon icon-base bi bi-people-fill"></i>
<div class="text-truncate">Sample</div> <div class="text-truncate">people</div>
</RouterLink> </RouterLink>
</li> --> </li>
</ul> </ul>
</aside> </aside>
<!-- / Menu --> <!-- / Menu -->
@ -94,6 +94,7 @@
<script setup> <script setup>
import { computed } from "vue"; import { computed } from "vue";
import { useUserInfoStore } from '@s/useUserInfoStore'; import { useUserInfoStore } from '@s/useUserInfoStore';
import "bootstrap-icons/font/bootstrap-icons.css";
const userStore = useUserInfoStore(); const userStore = useUserInfoStore();
const allowedUserId = 1; // ID (!!) const allowedUserId = 1; // ID (!!)

View File

@ -12,7 +12,7 @@
<img v-if="weather.icon" :src="customIconUrl" :alt="weather.description" :class="customIconClass" /> <img v-if="weather.icon" :src="customIconUrl" :alt="weather.description" :class="customIconClass" />
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<span class="weather-desc">{{ weather.description }}</span> <span class="weather-desc">{{ weather.description }}</span>
<span class="weather-temp" v-if="weather.tempMin !== null && weather.tempMax !== null"> <span class="weather-temp" v-if="weatherReady">
최저 {{ weather.tempMin }}° / 최고 {{ weather.tempMax }}° 최저 {{ weather.tempMin }}° / 최고 {{ weather.tempMax }}°
</span> </span>
</div> </div>
@ -38,135 +38,36 @@
<!-- Notification --> <!-- Notification -->
<li class="nav-item dropdown-notifications navbar-dropdown dropdown me-3 me-xl-2 p-0"> <li class="nav-item dropdown-notifications navbar-dropdown dropdown me-3 me-xl-2 p-0">
<a <a
class="nav-link dropdown-toggle hide-arrow p-0" class="nav-link dropdown-toggle hide-arrow p-0"
href="javascript:void(0);" href="javascript:void(0);"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
data-bs-auto-close="outside" data-bs-auto-close="outside"
aria-expanded="false" aria-expanded="false"
> >
<span class="position-relative"> <span class="position-relative">
<i class="bx bx-bell bx-md"></i> <i class="bx bx-bell bx-md"></i>
<span class="badge rounded-pill bg-danger badge-dot badge-notifications border"></span> <!-- 알림이 있을 경우에만 뱃지를 표시 -->
</span> <span
v-if="notificationCount > 0"
class="badge rounded-pill bg-danger badge-dot badge-notifications border"
></span>
</span>
</a> </a>
<ul class="dropdown-menu dropdown-menu-end p-0"> <ul class="dropdown-menu dropdown-menu-end p-0">
<li class="dropdown-menu-header border-bottom"> <li class="dropdown-notifications-list scrollable-container p-3">
<div class="dropdown-header d-flex align-items-center py-3"> <!-- 알림이 없으면 "알림이 없습니다." 메시지 표시 -->
<h6 class="mb-0 me-auto">Notification</h6> <div v-if="notificationCount === 0">
<div class="d-flex align-items-center h6 mb-0"> 알림이 없습니다.
<span class="badge bg-label-primary me-2">8 New</span> </div>
<a <!-- 알림이 있을 목록 렌더링-->
href="javascript:void(0)" <div v-else>
class="dropdown-notifications-all p-2" <ul>
data-bs-toggle="tooltip" <li v-for="notification in notifications" :key="notification.id">
data-bs-placement="top" {{ notification.text }}
title="Mark all as read" </li>
><i class="bx bx-envelope-open text-heading"></i </ul>
></a> </div>
</div> </li>
</div>
</li>
<li class="dropdown-notifications-list scrollable-container">
<ul class="list-group list-group-flush">
<li class="list-group-item list-group-item-action dropdown-notifications-item">
<div class="d-flex">
<div class="flex-shrink-0 me-3">
<div class="avatar">
<img src="/img/avatars/1.png" class="rounded-circle" />
</div>
</div>
<div class="flex-grow-1">
<h6 class="small mb-0">Congratulation Lettie 🎉</h6>
<small class="mb-1 d-block text-body">Won the monthly best seller gold badge</small>
<small class="text-muted">1h ago</small>
</div>
<div class="flex-shrink-0 dropdown-notifications-actions">
<a href="javascript:void(0)" class="dropdown-notifications-read"
><span class="badge badge-dot"></span
></a>
<a href="javascript:void(0)" class="dropdown-notifications-archive"
><span class="bx bx-x"></span
></a>
</div>
</div>
</li>
<li class="list-group-item list-group-item-action dropdown-notifications-item">
<div class="d-flex">
<div class="flex-shrink-0 me-3">
<div class="avatar">
<span class="avatar-initial rounded-circle bg-label-danger">CF</span>
</div>
</div>
<div class="flex-grow-1">
<h6 class="small mb-0">Charles Franklin</h6>
<small class="mb-1 d-block text-body">Accepted your connection</small>
<small class="text-muted">12hr ago</small>
</div>
<div class="flex-shrink-0 dropdown-notifications-actions">
<a href="javascript:void(0)" class="dropdown-notifications-read"
><span class="badge badge-dot"></span
></a>
<a href="javascript:void(0)" class="dropdown-notifications-archive"
><span class="bx bx-x"></span
></a>
</div>
</div>
</li>
<li class="list-group-item list-group-item-action dropdown-notifications-item marked-as-read">
<div class="d-flex">
<div class="flex-shrink-0 me-3">
<div class="avatar">
<img src="/img/avatars/2.png" class="rounded-circle" />
</div>
</div>
<div class="flex-grow-1">
<h6 class="small mb-0">New Message </h6>
<small class="mb-1 d-block text-body">You have new message from Natalie</small>
<small class="text-muted">1h ago</small>
</div>
<div class="flex-shrink-0 dropdown-notifications-actions">
<a href="javascript:void(0)" class="dropdown-notifications-read"
><span class="badge badge-dot"></span
></a>
<a href="javascript:void(0)" class="dropdown-notifications-archive"
><span class="bx bx-x"></span
></a>
</div>
</div>
</li>
<li class="list-group-item list-group-item-action dropdown-notifications-item">
<div class="d-flex">
<div class="flex-shrink-0 me-3">
<div class="avatar">
<span class="avatar-initial rounded-circle bg-label-success"
><i class="bx bx-cart"></i
></span>
</div>
</div>
<div class="flex-grow-1">
<h6 class="small mb-0">Whoo! You have new order 🛒</h6>
<small class="mb-1 d-block text-body">ACME Inc. made new order $1,154</small>
<small class="text-muted">1 day ago</small>
</div>
<div class="flex-shrink-0 dropdown-notifications-actions">
<a href="javascript:void(0)" class="dropdown-notifications-read"
><span class="badge badge-dot"></span
></a>
<a href="javascript:void(0)" class="dropdown-notifications-archive"
><span class="bx bx-x"></span
></a>
</div>
</div>
</li>
</ul>
</li>
<li class="border-top">
<div class="d-grid p-4">
<a class="btn btn-primary btn-sm d-flex" href="javascript:void(0);">
<small class="align-middle">View all notifications</small>
</a>
</div>
</li>
</ul> </ul>
</li> </li>
<!--/ Notification --> <!--/ Notification -->
@ -177,7 +78,7 @@
v-if="user" v-if="user"
:src="`${baseUrl}upload/img/profile/${user.profile}`" :src="`${baseUrl}upload/img/profile/${user.profile}`"
alt="Profile Image" alt="Profile Image"
class="w-px-40 h-px-40 rounded-circle border border-3 object-fit-contain" class="w-px-40 h-px-40 rounded-circle border border-3 object-fit-cover"
:style="`border-color: ${user.usercolor} !important;`" :style="`border-color: ${user.usercolor} !important;`"
@error="$event.target.src = '/img/icons/icon.png'" @error="$event.target.src = '/img/icons/icon.png'"
/> />
@ -230,6 +131,17 @@
const selectedProject = ref(null); const selectedProject = ref(null);
const weather = ref({}); const weather = ref({});
const dailyWeatherList = ref([]); const dailyWeatherList = ref([]);
const notifications = ref([]);
const notificationCount = ref(0);
const weatherReady = computed(() => {
return (
weather.value &&
weather.value.tempMin !== null &&
weather.value.tempMax !== null &&
!!weather.value.description
);
});
// //
const myActiveProjects = computed(() => { const myActiveProjects = computed(() => {
@ -283,8 +195,6 @@
return 'weather-icon'; return 'weather-icon';
}); });
const weatherKorean = computed(() => weather.value.description || '날씨 정보 없음');
// const { isDarkMode, switchToDarkMode, switchToLightMode } = useThemeStore(); // const { isDarkMode, switchToDarkMode, switchToLightMode } = useThemeStore();
const handleLogout = async () => { const handleLogout = async () => {
@ -320,7 +230,7 @@
// //
if (route.name !== 'login' && route.name !== undefined) { if (route.name !== 'login' && route.name !== undefined) {
// //
await weatherStore.getWeatherInfo(); await weatherStore.getWeatherInfoWithCache();
weather.value = weatherStore.weather; // weather.value = weatherStore.weather; //
dailyWeatherList.value = weatherStore.dailyWeatherList; // dailyWeatherList.value = weatherStore.dailyWeatherList; //
} }
@ -341,26 +251,35 @@
color: #888; color: #888;
line-height: 1.2; line-height: 1.2;
} }
.weather-box { /* .weather-box {
display: flex; display: flex;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
max-width: 3000px; max-width: 3000px;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
} } */
.custom-sunny-icon { .custom-sunny-icon {
width: 5%; width: 50px;
height: auto; height: 50px;
object-fit: contain;
flex-shrink: 0;
}
.weather-box {
display: flex;
align-items: center;
white-space: nowrap;
gap: 10px;
min-width: 160px; /* 필요시 */
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.custom-sunny-icon { .custom-sunny-icon {
width: 6%; width: 40px;
} }
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.custom-sunny-icon { .custom-sunny-icon {
width: 14%; width: 30px;
} }
} }
</style> </style>

View File

@ -22,6 +22,7 @@ const routes = [
}, },
{ {
path: 'write', path: 'write',
name: 'BoardWrite',
component: () => import('@v/board/BoardWrite.vue'), component: () => import('@v/board/BoardWrite.vue'),
}, },
{ {
@ -44,6 +45,7 @@ const routes = [
}, },
{ {
path: '/wordDict', path: '/wordDict',
name: 'WordDict',
component: () => import('@v/wordDict/wordDict.vue'), component: () => import('@v/wordDict/wordDict.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
@ -73,34 +75,46 @@ const routes = [
}, },
{ {
path: '/voteboard', path: '/voteboard',
name: 'VoteBoard',
component: () => import('@v/voteboard/TheVoteBoard.vue'), component: () => import('@v/voteboard/TheVoteBoard.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
children: [ children: [
{ {
path: '', path: '',
name: 'VoteBoardList',
component: () => import('@v/voteboard/voteBoardList.vue'), component: () => import('@v/voteboard/voteBoardList.vue'),
}, },
{ {
path: 'write', path: 'write',
name: 'VoteboardWrite',
component: () => import('@v/voteboard/voteboardWrite.vue'), component: () => import('@v/voteboard/voteboardWrite.vue'),
}, },
], ],
}, },
{ {
path: '/projectlist', path: '/projectlist',
name: 'Projectlist',
component: () => import('@v/projectlist/TheProjectList.vue'), component: () => import('@v/projectlist/TheProjectList.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
path: '/commuters', path: '/commuters',
name: 'Commuters',
component: () => import('@v/commuters/TheCommuters.vue'), component: () => import('@v/commuters/TheCommuters.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{ {
path: '/authorization', path: '/authorization',
name: 'Authorization',
component: () => import('@v/admin/TheAuthorization.vue'), component: () => import('@v/admin/TheAuthorization.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/people',
name: 'people',
component: () => import('@v/people/PeopleList.vue'),
meta: { requiresAuth: true },
},
{ {
path: '/error/400', path: '/error/400',
name: 'Error400', name: 'Error400',
@ -140,7 +154,7 @@ router.beforeEach(async (to, from, next) => {
// Authorization 페이지는 ID가 26이 아니면 접근 차단 // Authorization 페이지는 ID가 26이 아니면 접근 차단
if (to.path === '/authorization' && userId !== allowedUserId) { if (to.path === '/authorization' && userId !== allowedUserId) {
return next('/'); return next();
} }
// 비로그인 사용자만 접근 가능한 페이지인데 로그인된 경우 → 홈으로 이동 // 비로그인 사용자만 접근 가능한 페이지인데 로그인된 경우 → 홈으로 이동

View File

@ -1,9 +1,8 @@
/* /*
작성자 : 서지희 작성자 : 서지희
작성일 : 2025-04-04 작성일 : 2025-04-04
수정자 : 수정일 : 2025-04-07
수정일 : 설명 : 위치 기반으로 날씨를 조회하고, 10 단위 캐시로 저장합니다.
설명 : 위치 기반으로 날씨를 조회하고, 오늘의 최저/최고 기온과 현재 날씨 아이콘/설명을 저장합니다.
*/ */
import { ref } from 'vue'; import { ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
@ -20,66 +19,104 @@ export const useWeatherStore = defineStore('weather', () => {
const dailyWeatherList = ref([]); const dailyWeatherList = ref([]);
const getWeatherInfo = async () => { const getWeatherInfo = async () => {
navigator.geolocation.getCurrentPosition(async position => { return new Promise((resolve, reject) => {
const lat = position.coords.latitude; navigator.geolocation.getCurrentPosition(async position => {
const lon = position.coords.longitude; const lat = position.coords.latitude;
const lon = position.coords.longitude;
try { try {
const res = await $api.get(`/weather`, { const res = await $api.get(`/weather`, {
params: { lat, lon }, params: { lat, lon },
withCredentials: true, withCredentials: true,
}); });
if (!res?.data?.data) return; if (!res?.data?.data) return;
const resData = res.data.data; const resData = res.data.data;
const raw = resData.weatherInfo; const raw = resData.weatherInfo;
const data = JSON.parse(raw); const data = JSON.parse(raw);
if (!data || !Array.isArray(data.list) || data.list.length === 0) { if (!data || !Array.isArray(data.list)) {
console.error('날씨 데이터 형식 오류 또는 없음:', data); console.error('날씨 데이터 형식 오류');
return; return;
}
// 검은색 태양 아이콘 변경
dailyWeatherList.value = resData.dailyWeatherList.map(w => {
return {
...w,
icon: w.icon.replace(/n$/, 'd'),
};
});
const now = new Date();
const todayStr = now.toISOString().split('T')[0];
const nowTime = now.getTime();
const todayList = data.list.filter(item => item.dt_txt.startsWith(todayStr));
if (todayList.length > 0) {
const minTemp = Math.min(...todayList.map(i => i.main.temp_min));
const maxTemp = Math.max(...todayList.map(i => i.main.temp_max));
weather.value.tempMin = Math.round(minTemp);
weather.value.tempMax = Math.round(maxTemp);
}
const closest = data.list.reduce((prev, curr) => {
const prevDiff = Math.abs(new Date(prev.dt_txt).getTime() - nowTime);
const currDiff = Math.abs(new Date(curr.dt_txt).getTime() - nowTime);
return currDiff < prevDiff ? curr : prev;
});
weather.value.icon = closest.weather[0].icon.replace(/n$/, 'd');
weather.value.description = closest.weather[0].description;
resolve({ weather: weather.value, dailyWeatherList: dailyWeatherList.value });
} catch (e) {
console.error('날씨 정보 가져오기 실패:', e);
reject(e);
} }
}, reject);
// 주간 예보 리스트 저장
dailyWeatherList.value = resData.dailyWeatherList;
const now = new Date();
const nowTime = now.getTime();
const todayStr = now.toISOString().split('T')[0];
// 오늘의 데이터만 필터링
const todayList = data.list.filter(item => item.dt_txt.startsWith(todayStr));
if (todayList.length > 0) {
// 오늘의 최저 / 최고 기온 계산
const minTemp = Math.min(...todayList.map(i => i.main.temp_min));
const maxTemp = Math.max(...todayList.map(i => i.main.temp_max));
weather.value.tempMin = Math.round(minTemp);
weather.value.tempMax = Math.round(maxTemp);
} else {
weather.value.tempMin = null;
weather.value.tempMax = null;
}
// 현재 시간과 가장 가까운 시간대 데이터 추출 (아이콘 및 설명용)
const closest = data.list.reduce((prev, curr) => {
const prevDiff = Math.abs(new Date(prev.dt_txt).getTime() - nowTime);
const currDiff = Math.abs(new Date(curr.dt_txt).getTime() - nowTime);
return currDiff < prevDiff ? curr : prev;
});
weather.value.icon = closest.weather[0].icon.replace(/n$/, 'd');
weather.value.description = closest.weather[0].description;
} catch (e) {
console.error('날씨 정보 가져오기 실패:', e);
}
}); });
}; };
const getWeatherInfoWithCache = async () => {
const now = new Date();
const pad = n => String(n).padStart(2, '0');
const key = `weather_${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(Math.floor(now.getMinutes() / 10) * 10)}`;
const cached = localStorage.getItem(key);
if (cached) {
const parsed = JSON.parse(cached);
weather.value = parsed.weather;
dailyWeatherList.value = parsed.dailyWeatherList;
return;
}
try {
const { weather: w, dailyWeatherList: d } = await getWeatherInfo();
// 기존 캐시 삭제
Object.keys(localStorage).forEach(k => {
if (k.startsWith('weather_')) localStorage.removeItem(k);
});
localStorage.setItem(key, JSON.stringify({ weather: w, dailyWeatherList: d }));
} catch (e) {
console.error('날씨 API 호출 실패, 캐시 fallback 시도 중...');
const oldKey = Object.keys(localStorage)
.filter(k => k.startsWith('weather_'))
.sort()
.pop();
if (oldKey) {
const fallback = JSON.parse(localStorage.getItem(oldKey));
weather.value = fallback.weather;
dailyWeatherList.value = fallback.dailyWeatherList;
}
}
};
return { return {
weather, weather,
dailyWeatherList, dailyWeatherList,
getWeatherInfo, getWeatherInfo,
getWeatherInfoWithCache,
}; };
}); });

View File

@ -21,18 +21,18 @@
import MainVote from '@c/main/MainVote.vue'; import MainVote from '@c/main/MainVote.vue';
import { useUserInfoStore } from '@/stores/useUserInfoStore'; import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import $api from '@api';
const userStore = useUserInfoStore(); const userStore = useUserInfoStore();
const user = ref();
const isAdmin = ref(false); const isAdmin = ref(false);
const checkAdmin = user => { const checkAdmin = async user => {
return user?.value?.role === 'ROLE_ADMIN' ? true : false; const { data } = await $api.post('user/authCheck', { memberId: user.loginId });
return data.data === 'ROLE_ADMIN' ? true : false;
}; };
onMounted(async () => { onMounted(async () => {
await userStore.userInfo(); await userStore.userInfo();
user.value = userStore.user; isAdmin.value = await checkAdmin(userStore.user);
isAdmin.value = await checkAdmin(user);
}); });
</script> </script>

View File

@ -1,26 +1,35 @@
<template> <template>
<div class="container text-center flex-grow-1 container-p-y"> <div class="container text-center flex-grow-1 container-p-y">
<div class="card"> <div class="card">
<div class="card-header d-flex flex-column"> <div class="card-header d-flex flex-column">
<h3>관리자 권한 부여</h3> <h3>관리자 권한 부여</h3>
<div class="user-card-container"> <div class="user-card-container">
<div v-for="user in users" :key="user.id" class="user-card"> <div v-for="user in users" :key="user.id" class="user-card">
<!-- 프로필 사진 --> <!-- 프로필 사진 -->
<img :src="getProfileImage(user.photo)" class="profile-img" alt="프로필 사진" @error="setDefaultImage" /> <img
<!-- 사용자 정보 --> :src="getProfileImage(user.photo)"
<div class="user-info"> class="user-avatar2"
<h5>{{ user.name }}</h5> alt="프로필 사진"
</div> @error="setDefaultImage"
<!-- 권한 토글 버튼 --> />
<label class="switch me-0"> <!-- 사용자 정보 -->
<input type="checkbox" :checked="user.isAdmin" @change="toggleAdmin(user)" /> <div class="user-info">
<span class="slider round"></span> <h5>{{ user.name }}</h5>
</label>
</div>
</div>
</div> </div>
<!-- 권한 토글 버튼 (기본 동작 막고 클릭시 직접 토글 처리) -->
<label class="switch me-0">
<input
type="checkbox"
:checked="user.isAdmin"
@click="handleToggle($event, user)"
/>
<span class="slider round"></span>
</label>
</div>
</div> </div>
</div> </div>
</div>
</div>
</template> </template>
<script setup> <script setup>
@ -32,65 +41,80 @@ const users = ref([]);
const toastStore = useToastStore(); const toastStore = useToastStore();
const baseUrl = axios.defaults.baseURL.replace(/api\/$/, ""); const baseUrl = axios.defaults.baseURL.replace(/api\/$/, "");
const defaultProfile = "/img/icons/icon.png"; const defaultProfile = "/img/icons/icon.png";
const allowedUserId = 1; // ID (!!) const allowedUserId = 1; // ID ( )
// //
async function fetchUsers() { async function fetchUsers() {
try { try {
const response = await axios.get('admin/users'); // API const response = await axios.get('admin/users'); // API
if (!response.data || !Array.isArray(response.data.data)) {
// API throw new Error("올바른 데이터 형식이 아닙니다.");
if (!response.data || !Array.isArray(response.data.data)) {
throw new Error("올바른 데이터 형식이 아닙니다.");
}
// MEMBERSEQ 1
users.value = response.data.data
.filter(user => user.MEMBERSEQ !== allowedUserId) // MEMBERSEQ 1
.map(user => ({
id: user.MEMBERSEQ,
name: user.MEMBERNAM,
photo: user.MEMBERPRF ? `${baseUrl}upload/img/profile/${user.MEMBERPRF}` : defaultProfile,
color: user.MEMBERCOL,
isAdmin: user.MEMBERROL === 'ROLE_ADMIN',
}));
} catch (error) {
toastStore.onToast('사용자 목록을 불러오지 못했습니다.', 'e');
} }
users.value = response.data.data
.filter(user => user.MEMBERSEQ !== allowedUserId)
.map(user => ({
id: user.MEMBERSEQ,
name: user.MEMBERNAM,
photo: user.MEMBERPRF ? `${baseUrl}upload/img/profile/${user.MEMBERPRF}` : defaultProfile,
color: user.MEMBERCOL,
isAdmin: user.MEMBERROL === 'ROLE_ADMIN',
}));
} catch (error) {
toastStore.onToast('사용자 목록을 불러오지 못했습니다.', 'e');
}
} }
// //
function getProfileImage(photo) { function getProfileImage(photo) {
return photo || defaultProfile; return photo || defaultProfile;
} }
// //
function setDefaultImage(event) { function setDefaultImage(event) {
event.target.src = defaultProfile; event.target.src = defaultProfile;
} }
// //
async function toggleAdmin(user) { async function handleToggle(event, user) {
const requestData = { // Prevent the default checkbox toggle behavior
id: user.id, event.preventDefault();
role: user.isAdmin ? 'MEMBER' : 'ADMIN'
};
try {
const response = await axios.put('admin/role', requestData);
if (response.status === 200) { // : ( )
user.isAdmin = !user.isAdmin; const originalState = user.isAdmin;
toastStore.onToast(`'${user.name}'의 권한이 '${requestData.role}'(으)로 변경되었습니다.`, 's'); const newState = !originalState;
} else {
throw new Error('권한 변경 실패'); const requestData = {
} id: user.id,
} catch (error) { role: originalState ? 'MEMBER' : 'ADMIN'
toastStore.onToast('권한 변경에 실패했습니다.', 'e'); };
try {
const response = await axios.put('admin/role', requestData);
if (response.status === 200) {
//
user.isAdmin = newState;
toastStore.onToast(`'${user.name}'의 권한이 '${requestData.role}'(으)로 변경되었습니다.`, 's');
} else {
throw new Error('권한 변경 실패');
} }
} catch (error) {
//
toastStore.onToast('권한 변경에 실패했습니다.', 'e');
}
} }
onMounted(fetchUsers); onMounted(fetchUsers);
</script> </script>
<style scoped> <style scoped>
.user-avatar2 {
width: 160px;
height: 200px;
object-fit: cover;
border-radius: 50%;
display: block;
margin: 1rem auto 0 auto;
margin-top: 0px;
margin-bottom: 10px;
}
</style> </style>

View File

@ -99,7 +99,7 @@
// //
const title = ref(''); const title = ref('');
const content = ref(''); const content = ref({ ops: [] });
const autoIncrement = ref(0); const autoIncrement = ref(0);
// //
@ -130,10 +130,9 @@
// //
const isFirstContentUpdate = ref(true); const isFirstContentUpdate = ref(true);
// // ( )
const handleEditorDataUpdate = data => { const handleEditorDataUpdate = data => {
content.value = data; content.value = data;
if (isFirstContentUpdate.value) { if (isFirstContentUpdate.value) {
originalContent.value = structuredClone(data); originalContent.value = structuredClone(data);
isFirstContentUpdate.value = false; isFirstContentUpdate.value = false;
@ -141,23 +140,28 @@
} }
}; };
// isDeltaChanged ( diff , , )
function isDeltaChanged(current, original) { function isDeltaChanged(current, original) {
const Delta = Quill.import('delta'); const Delta = Quill.import('delta');
const currentDelta = new Delta(current || []); const currentDelta = new Delta(current || []);
const originalDelta = new Delta(original || []); const originalDelta = new Delta(original || []);
const diff = originalDelta.diff(currentDelta); //
if (!diff || diff.ops.length === 0) return false;
//
const getPlainText = delta => const getPlainText = delta =>
(delta.ops || []) (delta.ops || [])
.filter(op => typeof op.insert === 'string') .filter(op => typeof op.insert === 'string')
.map(op => op.insert) .map(op => op.insert)
.join(''); .join('');
// URL
const getImages = delta => const getImages = delta =>
(delta.ops || []).filter(op => typeof op.insert === 'object' && op.insert.image).map(op => op.insert.image); (delta.ops || [])
.filter(op => typeof op.insert === 'object' && op.insert.image)
.map(op => op.insert.image);
// URL
const getVideos = delta =>
(delta.ops || [])
.filter(op => typeof op.insert === 'object' && op.insert.video)
.map(op => op.insert.video);
const textCurrent = getPlainText(currentDelta); const textCurrent = getPlainText(currentDelta);
const textOriginal = getPlainText(originalDelta); const textOriginal = getPlainText(originalDelta);
@ -165,22 +169,27 @@
const imgsCurrent = getImages(currentDelta); const imgsCurrent = getImages(currentDelta);
const imgsOriginal = getImages(originalDelta); const imgsOriginal = getImages(originalDelta);
const textEqual = textCurrent === textOriginal; const vidsCurrent = getVideos(currentDelta);
const imageEqual = JSON.stringify(imgsCurrent) === JSON.stringify(imgsOriginal); const vidsOriginal = getVideos(originalDelta);
return !(textEqual && imageEqual); // false const textEqual = textCurrent === textOriginal;
const imageEqual = imgsCurrent.length === imgsOriginal.length && imgsCurrent.every((val, idx) => val === imgsOriginal[idx]);
const videoEqual = vidsCurrent.length === vidsOriginal.length && vidsCurrent.every((val, idx) => val === vidsOriginal[idx]);
return !(textEqual && imageEqual && videoEqual);
} }
//
const isChanged = computed(() => { const isChanged = computed(() => {
if (!contentInitialized.value) return false; if (!contentInitialized.value) return false;
const isTitleChanged = title.value !== originalTitle.value; const isTitleChanged = title.value !== originalTitle.value;
const isContentChanged = isDeltaChanged(content.value, originalContent.value); const isContentChanged = isDeltaChanged(content.value, originalContent.value);
const isFilesChanged = const isFilesChanged =
attachFiles.value.some(f => !f.id) || // id attachFiles.value.some(f => !f.id) || //
delFileIdx.value.length > 0 || // delFileIdx.value.length > 0 || //
!isSameFiles( !isSameFiles(
attachFiles.value.filter(f => f.id), // (id ) attachFiles.value.filter(f => f.id), //
originalFiles.value, originalFiles.value
); );
return isTitleChanged || isContentChanged || isFilesChanged; return isTitleChanged || isContentChanged || isFilesChanged;
}); });
@ -188,10 +197,8 @@
// //
function isSameFiles(current, original) { function isSameFiles(current, original) {
if (current.length !== original.length) return false; if (current.length !== original.length) return false;
const sortedCurrent = [...current].sort((a, b) => a.id - b.id); const sortedCurrent = [...current].sort((a, b) => a.id - b.id);
const sortedOriginal = [...original].sort((a, b) => a.id - b.id); const sortedOriginal = [...original].sort((a, b) => a.id - b.id);
return sortedCurrent.every((file, idx) => { return sortedCurrent.every((file, idx) => {
return file.id === sortedOriginal[idx].id && file.name === sortedOriginal[idx].name; return file.id === sortedOriginal[idx].id && file.name === sortedOriginal[idx].name;
}); });
@ -199,31 +206,24 @@
// //
const fetchBoardDetails = async () => { const fetchBoardDetails = async () => {
//
let password = accessStore.password; let password = accessStore.password;
const params = { const params = {
password: `${password}` || '', password: `${password}` || '',
}; };
//const response = await axios.get(`board/${currentBoardId.value}`);
const { data } = await axios.post(`board/${currentBoardId.value}`, params); const { data } = await axios.post(`board/${currentBoardId.value}`, params);
if (data.code !== 200) { if (data.code !== 200) {
//toastStore.onToast(data.message, 'e');
alert(data.message, 'e'); alert(data.message, 'e');
router.back(); router.back();
return; return;
} }
const boardData = data.data; const boardData = data.data;
//
if (boardData.hasAttachment && boardData.attachments.length > 0) { if (boardData.hasAttachment && boardData.attachments.length > 0) {
const formatted = addDisplayFileName([...boardData.attachments]); const formatted = addDisplayFileName([...boardData.attachments]);
attachFiles.value = formatted; attachFiles.value = formatted;
originalFiles.value = formatted; originalFiles.value = formatted;
} }
//
title.value = boardData.title || '제목 없음'; title.value = boardData.title || '제목 없음';
content.value = boardData.content || '내용 없음'; content.value = boardData.content || { ops: [] };
originalTitle.value = title.value; originalTitle.value = title.value;
originalContent.value = structuredClone(boardData.content); originalContent.value = structuredClone(boardData.content);
contentInitialized.value = true; contentInitialized.value = true;
@ -242,38 +242,34 @@
const addDisplayFileName = fileInfos => const addDisplayFileName = fileInfos =>
fileInfos.map(file => ({ fileInfos.map(file => ({
...file, ...file,
name: `${file.originalName}.${file.extension}`, name: `${file.originalName}.${file.extension}`
})); }));
// //
const goList = () => { const goList = () => {
accessStore.$reset(); accessStore.$reset();
//
// const getFilter = localStorage.getItem(`boardList_${currentBoardId.value}`);
// if (getFilter) {
// router.push({
// path: '/board',
// query: JSON.parse(getFilter),
// });
// } else {
// router.push('/board');
// }
router.back(); router.back();
}; };
// //
const goBack = () => { const goBack = () => {
accessStore.$reset(); accessStore.$reset();
router.back(); router.back();
}; };
// // ( : , , )
const checkValidation = () => { const isNotValidContent = delta => {
contentAlert.value = $common.isNotValidContent(content); if (!delta?.ops?.length) return true;
titleAlert.value = $common.isNotValidInput(title.value); const hasText = delta.ops.some(op => typeof op.insert === 'string' && op.insert.trim().length > 0);
const hasImage = delta.ops.some(op => op.insert && typeof op.insert === 'object' && op.insert.image);
const hasVideo = delta.ops.some(op => op.insert && typeof op.insert === 'object' && op.insert.video);
return !(hasText || hasImage || hasVideo);
};
//
const checkValidation = () => {
contentAlert.value = isNotValidContent(content.value);
titleAlert.value = $common.isNotValidInput(title.value);
if (titleAlert.value || contentAlert.value || !isFileValid.value) { if (titleAlert.value || contentAlert.value || !isFileValid.value) {
if (titleAlert.value) { if (titleAlert.value) {
title.value = ''; title.value = '';
@ -289,7 +285,6 @@
const handleFileUpload = files => { const handleFileUpload = files => {
const validFiles = files.filter(file => file.size <= maxSize); const validFiles = files.filter(file => file.size <= maxSize);
if (files.some(file => file.size > maxSize)) { if (files.some(file => file.size > maxSize)) {
fileError.value = '파일 크기가 10MB를 초과할 수 없습니다.'; fileError.value = '파일 크기가 10MB를 초과할 수 없습니다.';
return; return;
@ -300,13 +295,11 @@
} }
fileError.value = ''; fileError.value = '';
attachFiles.value = [...attachFiles.value, ...validFiles].slice(0, maxFiles); attachFiles.value = [...attachFiles.value, ...validFiles].slice(0, maxFiles);
autoIncrement.value++; autoIncrement.value++;
}; };
const removeFile = (index, file) => { const removeFile = (index, file) => {
if (file.id) delFileIdx.value.push(file.id); if (file.id) delFileIdx.value.push(file.id);
attachFiles.value.splice(index, 1); attachFiles.value.splice(index, 1);
if (attachFiles.value.length <= maxFiles) { if (attachFiles.value.length <= maxFiles) {
fileError.value = ''; fileError.value = '';
@ -324,55 +317,41 @@
}; };
////////////////// fileSection[E] //////////////////// ////////////////// fileSection[E] ////////////////////
/** `content` 변경 감지하여 자동 유효성 검사 실행 */ /** content 변경 감지 (deep 옵션 추가) */
watch(content, () => { watch(content, () => {
contentAlert.value = $common.isNotValidContent(content); contentAlert.value = isNotValidContent(content.value);
}); }, { deep: true });
// //
const validateTitle = () => { const validateTitle = () => {
titleAlert.value = title.value.trim().length === 0; titleAlert.value = title.value.trim().length === 0;
}; };
// //
const updateBoard = async () => { const updateBoard = async () => {
if (checkValidation()) return; if (checkValidation()) return;
//
const boardData = { const boardData = {
LOCBRDTTL: title.value.trim(), LOCBRDTTL: title.value.trim(),
LOCBRDCON: JSON.stringify(content.value), LOCBRDCON: JSON.stringify(content.value),
LOCBRDSEQ: currentBoardId.value, LOCBRDSEQ: currentBoardId.value
}; };
//
if (delFileIdx.value && delFileIdx.value.length > 0) { if (delFileIdx.value && delFileIdx.value.length > 0) {
boardData.delFileIdx = [...delFileIdx.value]; boardData.delFileIdx = [...delFileIdx.value];
} }
//
if (editorUploadedImgList.value && editorUploadedImgList.value.length > 0) { if (editorUploadedImgList.value && editorUploadedImgList.value.length > 0) {
boardData.editorUploadedImgList = [...editorUploadedImgList.value]; boardData.editorUploadedImgList = [...editorUploadedImgList.value];
} }
//
if (editorDeleteImgList.value && editorDeleteImgList.value.length > 0) { if (editorDeleteImgList.value && editorDeleteImgList.value.length > 0) {
boardData.editorDeleteImgList = [...editorDeleteImgList.value]; boardData.editorDeleteImgList = [...editorDeleteImgList.value];
} }
const fileArray = newFileFilter(attachFiles); const fileArray = newFileFilter(attachFiles);
const formData = new FormData(); const formData = new FormData();
// formData boardData
Object.entries(boardData).forEach(([key, value]) => { Object.entries(boardData).forEach(([key, value]) => {
formData.append(key, value); formData.append(key, value);
}); });
// formData
fileArray.forEach((file, idx) => { fileArray.forEach((file, idx) => {
formData.append('files', file); formData.append('files', file);
}); });
const { data } = await axios.put(`board/${currentBoardId.value}`, formData, { isFormData: true }); const { data } = await axios.put(`board/${currentBoardId.value}`, formData, { isFormData: true });
if (data.code === 200) { if (data.code === 200) {
toastStore.onToast('게시물이 수정되었습니다.', 's'); toastStore.onToast('게시물이 수정되었습니다.', 's');

View File

@ -47,7 +47,7 @@
<tr> <tr>
<th style="width: 11%" class="text-center fw-bold">번호</th> <th style="width: 11%" class="text-center fw-bold">번호</th>
<th style="width: 45%" class="text-center fw-bold">제목</th> <th style="width: 45%" class="text-center fw-bold">제목</th>
<th style="width: 10%" class="text-center fw-bold">작성자</th> <th style="width: 10%" class="text-strat fw-bold">작성자</th>
<th style="width: 15%" class="text-center fw-bold">작성일</th> <th style="width: 15%" class="text-center fw-bold">작성일</th>
<th style="width: 9%" class="text-center fw-bold">조회수</th> <th style="width: 9%" class="text-center fw-bold">조회수</th>
</tr> </tr>
@ -96,7 +96,7 @@
<td class="cursor-pointer"> <td class="cursor-pointer">
<div class="d-flex flex-wrap align-items-center"> <div class="d-flex flex-wrap align-items-center">
{{ truncateTitle(post.title) }} {{ truncateTitle(post.title) }}
<span v-if="post.commentCount" class="comment-count">[ {{ post.commentCount }} ]</span> <span v-if="post.commentCount" class="comment-count text-danger">[ {{ post.commentCount }} ]</span>
<i v-if="post.img" class="bi bi-image mx-1"></i> <i v-if="post.img" class="bi bi-image mx-1"></i>
<i <i
v-if="Array.isArray(post.hasAttachment) && post.hasAttachment.length > 0" v-if="Array.isArray(post.hasAttachment) && post.hasAttachment.length > 0"

View File

@ -37,7 +37,9 @@
</label> </label>
</div> </div>
</div> </div>
<div class="invalid-feedback" :class="categoryAlert ? 'd-block' : 'd-none'">카테고리를 선택해주세요.</div> <div class="invalid-feedback" :class="categoryAlert ? 'd-block' : 'd-none'">
카테고리를 선택해주세요.
</div>
</div> </div>
<!-- 비밀번호 필드 (익명게시판 선택 활성화) --> <!-- 비밀번호 필드 (익명게시판 선택 활성화) -->
@ -101,11 +103,14 @@
@update:deleteImgIndexList="handleDeleteEditorImg" @update:deleteImgIndexList="handleDeleteEditorImg"
/> />
</div> </div>
<div class="invalid-feedback mt-1" :class="contentAlert ? 'd-block' : 'd-none'">내용을 입력해주세요.</div> <div class="invalid-feedback mt-1" :class="contentAlert ? 'd-block' : 'd-none'">
내용을 입력해주세요.
</div>
</div> </div>
<div class="mb-4 d-flex justify-content-end"> <div class="mb-4 d-flex justify-content-end">
<BackButton @click="goList" /> <BackButton @click="goList" />
<!-- 저장 버튼은 항상 활성화 -->
<SaveButton @click="write" :isEnabled="isFileValid" /> <SaveButton @click="write" :isEnabled="isFileValid" />
</div> </div>
</div> </div>
@ -115,7 +120,7 @@
</template> </template>
<script setup> <script setup>
import { ref, onMounted, getCurrentInstance, watch, computed } from 'vue'; import { ref, onMounted, 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';
@ -169,10 +174,12 @@
const fileCount = computed(() => attachFiles.value.length); const fileCount = computed(() => attachFiles.value.length);
//
const handleUpdateEditorImg = item => { const handleUpdateEditorImg = item => {
editorUploadedImgList.value = item; editorUploadedImgList.value = item;
}; };
//
const handleDeleteEditorImg = item => { const handleDeleteEditorImg = item => {
editorDeleteImgList.value = item; editorDeleteImgList.value = item;
}; };
@ -187,10 +194,8 @@
fileError.value = `최대 ${maxFiles}개의 파일만 업로드할 수 있습니다.`; fileError.value = `최대 ${maxFiles}개의 파일만 업로드할 수 있습니다.`;
return; return;
} }
fileError.value = ''; fileError.value = '';
attachFiles.value = [...attachFiles.value, ...validFiles].slice(0, maxFiles); attachFiles.value = [...attachFiles.value, ...validFiles].slice(0, maxFiles);
autoIncrement.value++; autoIncrement.value++;
}; };
@ -213,7 +218,7 @@
const validateNickname = () => { const validateNickname = () => {
if (categoryValue.value === 300102) { if (categoryValue.value === 300102) {
nickname.value = nickname.value.replace(/\s/g, ''); // nickname.value = nickname.value.replace(/\s/g, ''); //
nicknameAlert.value = nickname.value.length === 0 ; nicknameAlert.value = nickname.value.length === 0;
} else { } else {
nicknameAlert.value = false; nicknameAlert.value = false;
} }
@ -228,19 +233,28 @@
} }
}; };
/**
* validateContent:
* - 내용이 없으면 contentAlert를 true로 설정
* - 텍스트, 이미지, 비디오 하나라도 존재하면 유효한 콘텐츠로 판단
*/
const validateContent = () => { const validateContent = () => {
if (!content.value?.ops?.length) { if (!content.value?.ops?.length) {
contentAlert.value = true; contentAlert.value = true;
return; return;
} }
// const hasText = content.value.ops.some(
const hasImage = content.value.ops.some(op => op.insert && typeof op.insert === 'object' && op.insert.image); op => typeof op.insert === 'string' && op.insert.trim().length > 0
// );
const hasText = content.value.ops.some(op => typeof op.insert === 'string' && op.insert.trim().length > 0); const hasImage = content.value.ops.some(
op => op.insert && typeof op.insert === 'object' && op.insert.image
);
const hasVideo = content.value.ops.some(
op => op.insert && typeof op.insert === 'object' && op.insert.video
);
// contentAlert.value = !(hasText || hasImage || hasVideo);
contentAlert.value = !(hasText || hasImage);
}; };
/** 글쓰기 */ /** 글쓰기 */
@ -264,7 +278,7 @@
try { try {
const boardData = { const boardData = {
LOCBRDTTL: title.value, LOCBRDTTL: title.value.trim(),
LOCBRDCON: JSON.stringify(content.value), // Delta JSON LOCBRDCON: JSON.stringify(content.value), // Delta JSON
LOCBRDNIC: categoryValue.value === 300102 ? nickname.value : null, LOCBRDNIC: categoryValue.value === 300102 ? nickname.value : null,
LOCBRDPWD: categoryValue.value === 300102 ? password.value : null, LOCBRDPWD: categoryValue.value === 300102 ? password.value : null,
@ -294,10 +308,10 @@
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('file', file); // 📌 formData.append('file', file);
await axios.post(`board/${boardId}/attachments`, formData, { isFormData: true }); await axios.post(`board/${boardId}/attachments`, formData, { isFormData: true });
}), })
); );
} }
@ -313,8 +327,8 @@
router.push('/board'); router.push('/board');
}; };
/** `content` 변경 감지하여 자동 유효성 검사 실행 */ /** content 변경 감지 (deep 옵션 추가) */
watch(content, () => { watch(content, () => {
validateContent(); validateContent();
}); }, { deep: true });
</script> </script>

View File

@ -6,11 +6,11 @@
<div class="text-center"> <div class="text-center">
<label <label
for="profilePic" for="profilePic"
class="rounded-circle m-auto ui-bg-cover position-relative cursor-pointer" class="rounded-circle cursor-pointer"
id="profileLabel" id="profileLabel"
:style="profilePreviewStyle" :style="profilePreviewStyle"
></label> ></label>
<input type="file" id="profilePic" class="d-none" @change="profileUpload" /> <input type="file" id="profilePic" class="d-none object-fit-cover" @change="profileUpload" />
<span v-if="profilerr" class="invalid-feedback d-block">{{ profilerr }}</span> <span v-if="profilerr" class="invalid-feedback d-block">{{ profilerr }}</span>
</div> </div>
@ -65,7 +65,7 @@
</span> </span>
<!-- 기존 비밀번호 입력 --> <!-- 기존 비밀번호 입력 -->
<UserFormInput title="기존 비밀번호" name="currentPw" type="password" <UserFormInput title="비밀번호 재설정" placeholder="기존 비밀번호를 입력하세요" name="currentPw" type="password"
:value="password.current" @update:data="password.current = $event" :value="password.current" @update:data="password.current = $event"
@blur="checkCurrentPassword" @keypress="noSpace" /> @blur="checkCurrentPassword" @keypress="noSpace" />
<span v-if="passwordError" class="text-danger invalid-feedback mt-1 d-block"> <span v-if="passwordError" class="text-danger invalid-feedback mt-1 d-block">

View File

@ -0,0 +1,318 @@
<template>
<div class="container-xxl flex-grow-1 container-p-y">
<div class="card">
<!-- 사원 목록이 없을 경우 표시 -->
<div v-if="allUserList.length === 0" class="text-center my-4">
<p class="text-muted">등록된 사원이 없습니다.</p>
</div>
<!-- 사원 카드 리스트 영역 -->
<div class="card-body">
<div class="card-list">
<div
v-for="(person, index) in allUserList"
:key="index"
class="person-card"
@click="openModal(person)"
>
<div>
<img
class="rounded-circle user-avatar pointer"
:src="getProfileImage(person.MEMBERPRF)"
:style="{ borderColor: person.usercolor }"
@error="setDefaultImage"
/>
</div>
<div class="card-body">
<h3 class="person-name">{{ person.MEMBERNAM }}</h3>
<p class="person-email">{{ person.MEMBERIDS }}@local-host.co.kr</p>
<p class="person-phone">{{ person.MEMBERTEL }}</p>
<small>
{{ person.MEMBERARR }} {{ person.MEMBERDTL }}
</small>
</div>
</div>
</div>
</div>
<!-- 상세보기 Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-content">
<button class="close-btn" @click="closeModal">×</button>
<div class="modal-body">
<img
class="user-avatar2"
:src="getProfileImage(selectedPerson.MEMBERPRF)"
:style="{ borderColor: selectedPerson.usercolor }"
@error="setDefaultImage"
/>
<h4>{{ selectedPerson.MEMBERNAM }}</h4>
<p>{{ selectedPerson.MEMBERIDS }}@local-host.co.kr</p>
<p>{{ selectedPerson.MEMBERTEL }}</p>
<p>{{ selectedPerson.MEMBERARR }} {{ selectedPerson.MEMBERDTL }}</p>
<hr />
<!-- 추가 정보: 사용자가 속한 프로젝트 목록 -->
<h5>참여 프로젝트</h5>
<div v-if="memberProjects.length > 0" class="project-list-container">
<ul>
<li
v-for="(project, idx) in memberProjects"
:key="idx"
class="project-item"
>
<span class="project-name">{{ project.PROJCTNAM }}</span>
<span class="project-period">
<!-- projectEndDate가 있는 경우 -->
<!-- <template v-if="project.projectEndDate"> -->
{{ project.userStartDate ? project.userStartDate : project.projectStartDate }} ~
{{ project.userEndDate ? project.userEndDate : project.projectEndDate }}
<!-- </template> -->
<!-- 없으면 종료일 표시 안함 -->
<!-- <template v-else>
{{ project.userStartDate ? project.userStartDate : project.projectStartDate }} ~
</template> -->
</span>
</li>
</ul>
</div>
<div v-else>
<p>참여중인 프로젝트가 없습니다.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from '@api' // API Axios
import { ref, onMounted } from 'vue'
import SearchBar from '@c/search/SearchBar.vue'
export default {
name: 'PeopleList',
components: { SearchBar },
setup() {
const allUserList = ref([]) //
const user = ref({}) // ( )
const showModal = ref(false) //
const selectedPerson = ref({})//
const memberProjects = ref([])//
onMounted(async () => {
try {
const response = await axios.get('user/allUserList')
allUserList.value = response.data.data.allUserList
user.value = response.data.data.user
} catch (error) {
console.error('사원 목록 조회 실패:', error)
}
})
const baseUrl = axios.defaults.baseURL.replace(/api\/$/, '')
const defaultProfile = '/img/icons/icon.png'
const getProfileImage = (profilePath) => {
return profilePath && profilePath.trim()
? `${baseUrl}upload/img/profile/${profilePath}`
: defaultProfile
}
const setDefaultImage = (event) => {
event.target.src = defaultProfile
}
// API
const fetchMemberProjects = async (memberSeq) => {
try {
const res = await axios.get(`project/people/${memberSeq}`)
memberProjects.value = res.data.data
} catch (error) {
console.error('프로젝트 조회 실패:', error)
memberProjects.value = []
}
}
const openModal = (person) => {
selectedPerson.value = person
fetchMemberProjects(person.MEMBERSEQ)
showModal.value = true
}
const closeModal = () => {
showModal.value = false
}
return {
allUserList,
user,
showModal,
selectedPerson,
memberProjects,
openModal,
closeModal,
getProfileImage,
defaultProfile,
setDefaultImage
}
}
}
</script>
<style scoped>
.container-xxl {
padding: 1rem;
}
.card-list {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
}
.person-card {
width: 280px;
border: 1px solid #eee;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: #fff;
transition: box-shadow 0.2s ease-in-out;
}
.person-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.person-card .card-header {
width: 100%;
height: 120px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
}
.user-avatar {
width: 160px;
height: 200px;
object-fit: cover;
border-radius: 50%;
border: 2px solid #ddd;
display: block;
margin: 1rem auto 0 auto;
}
.user-avatar2 {
width: 160px;
height: 200px;
object-fit: cover;
border-radius: 50%;
display: block;
margin: 1rem auto 0 auto;
margin-top: 0px;
margin-bottom: 10px;
}
.person-card .card-body {
padding: 0.75rem;
}
.person-name {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.person-email,
.person-phone {
margin: 0;
font-size: 0.9rem;
color: #555;
}
/* 모달 스타일 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 111%;
background-color: rgba(0, 0, 0, 0.4);
display: flex;
justify-content: center;
align-items: center;
z-index: 999;
}
.modal-content {
position: relative;
width: 400px;
background: #fff;
padding: 1.5rem;
border-radius: 8px;
animation: slideDown 0.3s ease forwards;
}
.close-btn {
background: transparent;
border: none;
font-size: 1.5rem;
position: absolute;
top: 0.5rem;
right: 0.5rem;
cursor: pointer;
}
.modal-body {
text-align: center;
}
.modal-img {
width: 50%;
height: 50%;
border-radius: 50%;
margin-bottom: 1rem;
object-fit: cover;
}
/* 프로젝트 리스트 스타일 */
.project-list-container {
max-height: 200px; /* 필요에 따라 높이 조절 */
overflow-y: auto;
margin-top: 1rem;
}
.project-item {
display: flex;
align-items: center;
list-style: none;
font-size: 0.9rem;
padding: 0.25rem 0;
}
.project-name {
font-weight: 600;
}
.project-period {
font-size: 1rem;
color: #888;
margin-left: 10px;
}
@keyframes slideDown {
0% {
transform: translateY(-15%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
</style>

View File

@ -138,13 +138,15 @@ const checkedNames = (numList) => {
} }
// //
const endVoteId = (endVoteId) => { const endVoteId = (endVoteId) => {
$api.patch('vote/updateEndData',{ if(confirm('투표를 종료하시겠습니까?')){
endVoteId :endVoteId $api.patch('vote/updateEndData',{
}).then((res)=>{ endVoteId :endVoteId
if(res.data.status === 'OK'){ }).then((res)=>{
getvoteList(); if(res.data.status === 'OK'){
} getvoteList();
}) }
})
}
} }
// //
const voteEnded = async (id) =>{ const voteEnded = async (id) =>{
@ -152,14 +154,16 @@ const voteEnded = async (id) =>{
} }
// //
const voteDelete =(id) =>{ const voteDelete =(id) =>{
$api.patch('vote/updateDeleteData',{ if(confirm('투표를 삭제하시겠습니까?')){
deleteVoteId :id $api.patch('vote/updateDeleteData',{
}).then((res)=>{ deleteVoteId :id
if(res.data.status === 'OK'){ }).then((res)=>{
toastStore.onToast('투표가 삭제되었습니다.', 's'); if(res.data.status === 'OK'){
getvoteList(); toastStore.onToast('투표가 삭제되었습니다.', 's');
} getvoteList();
}) }
})
}
} }
// 1 // 1
const randomList = (data,id) =>{ const randomList = (data,id) =>{

View File

@ -244,4 +244,14 @@ const goList = () => {
.item-input { .item-input {
max-width: 200px; max-width: 200px;
} }
.hidden-date-input {
display: block; /* 한 줄 차지 */
margin-top: 19.5px; /* form-input과 붙게 조정 */
border: none;
padding: 0;
height: 0;
opacity: 0;
pointer-events: none; /* 사용자 클릭 못하게 */
position: absolute; /* 시각적으로 띄워두기 */
}
</style> </style>

View File

@ -7,7 +7,7 @@
<div class="d-flex"> <div class="d-flex">
<!-- 단어 갯수, 작성하기 --> <!-- 단어 갯수, 작성하기 -->
<!-- 왼쪽 사이드바 --> <!-- 왼쪽 사이드바 -->
<div class="sidebar position-sticky" style="top: 100px; max-width: 250px; min-width: 250px;"> <div v-if="cateList.length>0" class="sidebar position-sticky" style="top: 100px; max-width: 250px; min-width: 250px;">
<WriteButton ref="writeButton" @click="writeStore.toggleItem(999999)" :isToggleEnabled="true" <WriteButton ref="writeButton" @click="writeStore.toggleItem(999999)" :isToggleEnabled="true"
:isActive="writeStore.activeItemId === 999999"/> :isActive="writeStore.activeItemId === 999999"/>
<!-- --> <!-- -->
@ -17,8 +17,12 @@
<CategoryBtn :lists="cateList" @update:data="handleSelectedCategoryChange" :showAll="true" :selectedCategory="selectedCategory" /> <CategoryBtn :lists="cateList" @update:data="handleSelectedCategoryChange" :showAll="true" :selectedCategory="selectedCategory" />
</div> </div>
</div> </div>
<div v-else>
<WriteButton ref="writeButton" @click="writeStore.toggleItem(999999)" :isToggleEnabled="true"
:isActive="writeStore.activeItemId === 999999"/>
</div>
<!-- 용어 리스트 컨텐츠 --> <!-- 용어 리스트 컨텐츠 -->
<div class="flex-grow-1"> <div class="flex-grow-1">
<!-- 작성 --> <!-- 작성 -->
<div v-if="writeStore.isItemActive(999999)" class="ms-3 card p-5 mb-2"> <div v-if="writeStore.isItemActive(999999)" class="ms-3 card p-5 mb-2">
@ -29,9 +33,9 @@
<!-- 에러 메시지 --> <!-- 에러 메시지 -->
<div v-if="error" class="fw-bold text-danger">{{ error }}</div> <div v-if="error" class="fw-bold text-danger">{{ error }}</div>
<!-- 단어 목록 --> <!-- 단어 목록 -->
<ul v-if="total > 0" class="ms-3 list-unstyled" style="overflow-x: hidden; word-wrap: break-word;"> <ul v-if="total > 0" class="ms-3 list-unstyled">
<DictCard <DictCard
class="DictCard q-editor-container" class="q-editor-container"
v-for="item in wordList" v-for="item in wordList"
:key="item.WRDDICSEQ" :key="item.WRDDICSEQ"
:item="item" :item="item"
@ -331,15 +335,12 @@ import { useRoute } from 'vue-router';
top: 5px; top: 5px;
height: fit-content; height: fit-content;
} }
.DictCard {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.q-editor-container { .q-editor-container {
max-width: 100%; /* 영역이 넘치지 않게 */ max-width: 100%; /* 영역이 넘치지 않게 */
overflow: auto; /* 넘치는 내용은 스크롤로 처리 */ overflow: auto; /* 넘치는 내용은 스크롤로 처리 */
word-wrap: break-word; /* 긴 단어는 자동으로 줄바꿈 */ word-wrap: break-word; /* 긴 단어는 자동으로 줄바꿈 */
white-space: normal; /* 내용이 길어지면 자동으로 줄바꿈 */ white-space: normal; /* 내용이 길어지면 자동으로 줄바꿈 */
} }
</style> </style>