Compare commits

...

181 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
1c483ef727 게시글 상세 목록으로 버튼 추가 2025-04-07 20:43:33 +09:00
9e578109e1 Merge branch '250407_park' 2025-04-07 20:11:00 +09:00
6f91dd9379 메인페이지 휴가이벤트 누르면 휴가페이지로 이동 2025-04-07 20:10:47 +09:00
yoon
4635c9b372 . 2025-04-07 16:54:06 +09:00
yoon
1d6bc44680 반명항 기준 프로필 이미지 수정 2025-04-07 16:46:51 +09:00
ffc8b44b46 날씨 스토리지에저장 2025-04-07 16:15:49 +09:00
64bba660cd Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-07 15:54:19 +09:00
5e1c8795e6 용어집 영억,짤림 수정정 2025-04-07 15:53:53 +09:00
17c3aefd2b 용어집 영역 짤림 /글 작성시 계속 입력할때 옆으로 계속 늘어남 수정정 2025-04-07 15:53:07 +09:00
6ee96f3abd Merge branch '250407_park3' 2025-04-07 15:43:40 +09:00
ee3027b3e1 댓글 버튼 active 관련 수정 2025-04-07 15:43:23 +09:00
0a0c7b4244 . 2025-04-07 15:38:37 +09:00
25c691338d 수정사항 2025-04-07 15:37:58 +09:00
1f9f9bd1ff .. 2025-04-07 15:01:38 +09:00
a02a316370 Merge branch 'khj'
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-07 13:40:35 +09:00
2b319c99b8 토탈수 변경경 2025-04-07 13:39:53 +09:00
594568ed2d ..
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-07 13:37:14 +09:00
e61ed9bc87 obecjt-fit contain 적용
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-07 13:25:41 +09:00
348fd83642 profile img 클래스 css 제거(중복)
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-07 13:20:26 +09:00
29f97e8aba 당일 날짜에 이벤트 입력 후 디폴트 색깔 없어짐 수정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-07 13:10:23 +09:00
84c4e2a7ad 라우트 로딩 후 app 마우트 되도록 수정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-07 12:55:11 +09:00
fc32aba4ab 123
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-07 11:50:19 +09:00
0a3f639d06 게시글 상세 화면 로딩 시 스크롤 매위로 2025-04-07 11:33:36 +09:00
19c574cf9d 이미지 높이 수정 2025-04-07 11:26:56 +09:00
feb10ebae5 사원등록 mbti 추가 2025-04-07 11:13:11 +09:00
2e94e96d5f Merge branch 'khj' 2025-04-07 10:46:30 +09:00
81f7e15604 더보기 ㅁ버튼 변경경 2025-04-07 10:46:09 +09:00
d2d0f2892d 댓글 수정시 maxlength 500 2025-04-07 10:43:21 +09:00
b4d8d09986 Merge branch '250407_main_park'
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-07 10:37:21 +09:00
135e16e709 수정버튼 클래스 수정 2025-04-07 10:36:41 +09:00
47c28030cf Merge branch 'main' into board-ji 2025-04-07 10:35:38 +09:00
7e02512011 Merge branch 'main' into board-ji 2025-04-07 10:35:15 +09:00
eb39a2a0b7 수정사항 2025-04-07 10:34:49 +09:00
c1a45f7d02 g 2025-04-07 10:25:48 +09:00
34f6bad788 Merge branch 'khj' 2025-04-07 10:25:03 +09:00
d2864f7ac0 용어집 수정정 2025-04-07 10:24:39 +09:00
3be8560654 이벤트 폰트 크기 변경 2025-04-07 10:20:05 +09:00
08331b7b58 공휴일 style 달력 좌우로 넓게 나오게 2025-04-05 10:03:15 +09:00
b8a7310af0 프로필 이미지 나오게, 아무기능없는 포인터 제거 2025-04-05 00:59:37 +09:00
44cec4cccd 로그인 페이지에 날씨정보 안가져오게
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-05 00:25:25 +09:00
08d30c7fc4 유효성 체크 및 경고 문구 변경 2025-04-04 23:34:57 +09:00
04f4346122 1 2025-04-04 22:59:36 +09:00
152d3c6fa9 게시글 수정화면에서 콘솔로그 제거 2025-04-04 22:59:33 +09:00
yoon
bba87fe02a select 2025-04-04 19:21:39 +09:00
yoon
da4e069b59 위치수정ㅇ 2025-04-04 17:20:57 +09:00
yoon
b24c6d85e4 뒤로가기 버튼 2025-04-04 17:06:42 +09:00
yoon
3e873b7861 Merge branch 'main' into commuters 2025-04-04 16:47:02 +09:00
yoon
9f494d9d12 위치변경 2025-04-04 16:41:36 +09:00
yoon
5f9a99dd02 컬러 2025-04-04 16:35:37 +09:00
yoon
17e86f49d0 컬러, 휴대전화 2025-04-04 16:32:53 +09:00
yoon
f18aa4dc16 모달 닫을 때 맵 닫기 2025-04-04 16:32:27 +09:00
d1aa3d3a04 Merge branch 'main' into mypage 2025-04-04 16:20:44 +09:00
b33f38b5a5 Merge branch 'main' into mypage 2025-04-04 16:20:34 +09:00
2ffd88cb68 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-04-04 16:18:37 +09:00
c4b40dea79 용어집 수정사항 없을떄 저장 x 2025-04-04 16:18:35 +09:00
5390988afa .. 2025-04-04 16:17:13 +09:00
080511f428 메인보드 2025-04-04 16:14:42 +09:00
582030fd5d 탑바수정 2025-04-04 16:04:30 +09:00
yoon
ba12c0ad2d cursor-pointer 2025-04-04 14:49:32 +09:00
yoon
358f4a343a 컬러 없을 때 2025-04-04 14:48:09 +09:00
yoon
142b576804 남은 컬러 없는 경우 2025-04-04 14:47:56 +09:00
d792253292 t
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-04 14:22:17 +09:00
03aba73bdb 이미지꺠짐 수정정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-04 14:09:31 +09:00
a438965787 Merge branch 'khj' 2025-04-04 14:04:47 +09:00
bb9a140aa9 수정정 2025-04-04 14:04:10 +09:00
daa3bb9921 탑뷰수정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-04 13:30:38 +09:00
4907a12e65 t 2025-04-04 13:24:14 +09:00
87465c01fa Merge branch 'khj' 2025-04-04 13:22:43 +09:00
a0c2aa04ad 투표 수정정 2025-04-04 13:22:16 +09:00
3f37370d0d 에민 보드 폰트크기 수정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-04 13:21:38 +09:00
f6365e6b31 입력 전처리
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-04 13:08:20 +09:00
b2deb1ade2 Merge branch 'main' into mypage 2025-04-04 12:57:41 +09:00
a6c57646d7 Merge branch 'main' into mypage 2025-04-04 12:57:29 +09:00
819a82cd89 // 2025-04-04 12:56:53 +09:00
a27eda7124 . 2025-04-04 11:40:52 +09:00
4d8e4f7b3e . 2025-04-04 11:39:59 +09:00
5a60012ce6 컬러,전화번호 중복체크 2025-04-04 11:36:04 +09:00
c60bed282f 마이페이지 2025-04-04 11:08:05 +09:00
a97d3b4609 Merge branch 'khj' 2025-04-04 10:38:14 +09:00
78eefe5a7f 투표 수정정 2025-04-04 10:37:48 +09:00
e4b25680da Merge branch '250403_board_commentModify_park' 2025-04-03 22:18:08 +09:00
7e0ae7063d boardview 에서 수정, 삭제시 버튼 클릭 활성화 2025-04-03 22:17:30 +09:00
1fef343fd9 종료수정정 2025-04-03 16:18:50 +09:00
ea6b5d26c9 Merge branch 'khj' 2025-04-03 16:13:29 +09:00
36888c3e30 투표하기 모달 2025-04-03 16:12:57 +09:00
a5fe714c73 마이페이지 2025-04-03 15:51:30 +09:00
2072a41ca9 로그인창에서 새로고침시 날씨 401에러나는거 수정 2025-04-03 12:17:08 +09:00
29ee90a84e 투표용어 메인수정정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-03 11:17:11 +09:00
fb5a0d6363 날짜 스타일변경경 2025-04-03 11:03:27 +09:00
9548025afd 이미지수정정 2025-04-03 10:59:46 +09:00
55c0c33894 Merge branch 'khj' 2025-04-03 10:44:14 +09:00
60df96cf12 투표 메인 수정정 2025-04-03 10:43:51 +09:00
3b45ae929e 게시판 리스트 수정 2025-04-03 10:39:07 +09:00
53b4519985 Merge branch 'main' into vacation 2025-04-03 10:35:23 +09:00
4cd2ddb102 Merge branch 'main' into vacation 2025-04-03 10:35:11 +09:00
8b2262d633 당일 css 수정 2025-04-03 10:30:49 +09:00
e1e8c27db0 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-04-03 10:13:34 +09:00
2e5ce81981 Merge branch 'khj' 2025-04-03 10:13:32 +09:00
26c3f3ec41 용어집 스타일 수정 2025-04-03 10:13:06 +09:00
caba716ac6 Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front 2025-04-03 10:12:49 +09:00
e1cc692085 메인 달력 사원 이미지 url 수정 2025-04-03 10:12:46 +09:00
3cf6aeab9a Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-04-03 10:07:07 +09:00
7e2caa5f09 . 2025-04-03 10:07:05 +09:00
ff0ea598ec 메인달력 STYLE 이 다른곳에 적용안되게 수정, 2025-04-03 10:02:50 +09:00
54 changed files with 2441 additions and 1142 deletions

View File

@ -24,7 +24,6 @@
font-size: 18px;
}
@keyframes new {
0% {
background-color: #ffcc00;
@ -43,7 +42,6 @@
/* board end */
/* Qeditor */
.ql-editor {
min-height: 300px;
@ -174,6 +172,9 @@
.fc-toolbar-title {
cursor: pointer;
}
.fc-today-button {
cursor: pointer !important;
}
/* 클릭 가능한 날짜 */
.fc-daygrid-day.clickable {
cursor: pointer;
@ -430,8 +431,8 @@
/* 작은 화면에서 버튼 크기 조정 */
@media (max-width: 1700px) {
.count-btn {
width: 28px;
height: 28px;
width: 26px;
height: 26px;
font-size: 15px;
}
.count-container {
@ -442,7 +443,7 @@
margin-bottom: 8px;
}
.count-value {
font-size: 20px;
font-size: 15px;
}
.custom-button {
width: 45px;
@ -453,10 +454,10 @@
font-size: 18px;
}
.vac-modal-text {
font-size: 13px;
font-size: 12px;
}
.vac-modal-title {
font-size: 17px;
font-size: 15px;
margin-bottom: 10px;
}
.vacation-item {
@ -465,8 +466,8 @@
margin-bottom: 5px;
}
.vac-btn {
width: 50px;
height: 50px;
width: 40px;
height: 40px;
font-size: 18px;
}
.vac-btn-success {
@ -497,12 +498,12 @@
margin-bottom: 5px;
}
.vac-btn {
width: 40px;
height: 40px;
font-size: 18px;
width: 10px;
height: 10px;
font-size: 12px;
}
.vac-btn-success {
font-size: 20px;
font-size: 15px;
width: 40px;
height: 40px;
}
@ -587,7 +588,7 @@
scrollbar-width: none;
}
.fc-daygrid-day[data-has-commuters="true"] {
.fc-daygrid-day[data-has-commuters='true'] {
cursor: pointer;
}
@ -698,7 +699,6 @@
/* Mobile end */
/* media end */
/* BoardComment */
.beforeRotate {
@ -722,6 +722,12 @@
pointer-events: none;
}
.hidden-time-input {
position: absolute;
opacity: 0;
pointer-events: none;
}
/* 권한부여 */
.user-card-container {
display: flex;
@ -769,22 +775,22 @@
right: 0;
bottom: 0;
background-color: #ccc;
transition: .4s;
transition: 0.4s;
border-radius: 24px;
}
.slider:before {
position: absolute;
content: "";
content: '';
height: 18px;
width: 18px;
left: 4px;
bottom: 3px;
background-color: white;
transition: .4s;
transition: 0.4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: #4CAF50;
background-color: #4caf50;
}
input:checked + .slider:before {
@ -792,7 +798,6 @@ input:checked + .slider:before {
}
/* 권한부여 끝 */
/* toast */
.bs-toast {
@ -802,6 +807,28 @@ input:checked + .slider:before {
/* toast end */
.cursor-none{
.cursor-none {
cursor: none !important;
}
}
.ml-1 {
margin-left: 0.25rem !important;
}
.mr-1 {
margin-right: 0.25rem !important;
}
.nickname-ellipsis {
white-space: nowrap;
max-width: 100px;
vertical-align: middle;
}
/* font css */
.font-bold {
font-weight: bold;
}
.pointer {
cursor: pointer;
}

View File

@ -12,6 +12,8 @@
:isLike="!isLike"
:isCommentPassword="isCommentPassword"
:isCommentProfile="true"
:is-edit-pushed="isEditPushed"
:is-delete-pushed="isDeletePushed"
@editClick="handleEditClick"
@deleteClick="$emit('deleteClick', comment)"
@updateReaction="handleUpdateReaction"
@ -35,7 +37,7 @@
<div class="mt-6">
<template v-if="comment.isEditTextarea">
<textarea v-model="localEditedContent" class="form-control"></textarea>
<textarea v-model="localEditedContent" class="form-control" maxLength="500"></textarea>
<span v-if="editCommentAlert" class="invalid-feedback d-block text-start">{{ editCommentAlert }}</span>
<div class="mt-2 d-flex justify-content-end">
<SaveBtn class="btn btn-primary" @click="submitEdit" :isEnabled="disabled"></SaveBtn>
@ -116,9 +118,20 @@
password: {
type: String,
},
// isEditPushed: {
// type: Boolean,
// required: false,
// },
// isDeletePushed: {
// type: Boolean,
// required: false,
// },
editCommentAlert: String,
});
const isEditPushed = ref(false);
const isDeletePushed = ref(false);
const displayName = computed(() => {
return props.nickname ? props.nickname : props.comment.author;
});
@ -171,6 +184,24 @@
emit('submitPassword', props.comment, props.password);
};
const handleInject = inject('isBtnPushed');
// ,
watch(
() => handleInject.value,
(newValue, oldValue) => {
if (newValue) {
if (newValue.target == props.comment.commentId) {
isEditPushed.value = newValue.isEditPushed;
isDeletePushed.value = newValue.isDeletePushed;
} else {
isEditPushed.value = false;
isDeletePushed.value = false;
}
}
},
);
watch(
() => props.comment.isEditTextarea,
newVal => {

View File

@ -41,6 +41,8 @@
v-model="nickname"
placeholder="닉네임"
@input="clearAlert('nickname')"
@keypress="noSpace"
:maxlength="6"
/>
<!-- 닉네임 경고 메시지 -->
<div v-if="nicknameAlert" class="position-absolute text-danger small top-100 start-0">
@ -109,6 +111,10 @@
},
});
const noSpace = (e) => {
if (e.key === ' ') e.preventDefault();
};
const $common = inject('common');
const comment = ref('');
const password = ref('');

View File

@ -13,6 +13,8 @@
:currentPasswordCommentId="currentPasswordCommentId"
:password="password"
:editCommentAlert="editCommentAlert[comment.commentId]"
:is-edit-pushed="comment.isEditPushed"
:is-delete-pushed="comment.isDeletePushed"
@editClick="handleEditClick"
@deleteClick="handleDeleteClick"
@submitPassword="submitPassword"
@ -40,6 +42,8 @@
:passwordCommentAlert="passwordCommentAlert"
:password="password"
:editCommentAlert="editCommentAlert[child.commentId]"
:is-edit-pushed="child.isEditPushed"
:is-delete-pushed="child.isDeletePushed"
@editClick="handleReplyEditClick"
@deleteClick="$emit('deleteClick', child)"
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent, child.commentId)"
@ -59,7 +63,7 @@
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import { defineProps, defineEmits, watch } from 'vue';
import BoardComment from './BoardComment.vue';
const props = defineProps({

View File

@ -5,7 +5,7 @@
<img
:src="getProfileImage(profileImg)"
alt="user"
class="rounded-circle"
class="rounded-circle profile-img"
@error="setDefaultImage($event)"
@load="showImage($event)"
/>
@ -27,8 +27,14 @@
<!-- 수정, 삭제 버튼 -->
<template v-if="!isDeletedComment && (unknown || isCommentAuthor || isAuthor)">
<div class="float-end ms-1">
<EditButton @click.stop="editClick" />
<DeleteButton :class="'ms-1'" @click.stop="deleteClick" />
<slot name="gobackBtn"></slot>
<EditButton @click.stop="editClick" :is-pushed="isEditPushed" />
<DeleteButton :class="'ms-1'" @click.stop="deleteClick" :is-pushed="isDeletePushed" />
</div>
</template>
<template v-else>
<div class="float-end ms-1">
<slot name="gobackBtn"></slot>
</div>
</template>
@ -107,6 +113,14 @@
type: String,
default: false,
},
isEditPushed: {
type: Boolean,
require: false,
},
isDeletePushed: {
type: Boolean,
require: false,
},
});
const emit = defineEmits(['updateReaction', 'editClick', 'deleteClick']);

View File

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

View File

@ -1,13 +1,14 @@
<template>
<button class="btn btn-label-primary btn-icon">
<i class='bx bx-trash' ></i>
<button class="btn btn-label-primary btn-icon" :class="{ active: props.isPushed }">
<i class="bx bx-trash"></i>
</button>
</template>
<script>
export default {
name: 'DeleteButton',
methods: {
},
};
<script setup>
const props = defineProps({
isPushed: {
type: Boolean,
required: false,
},
});
</script>

View File

@ -1,10 +1,10 @@
<template>
<button class="btn btn-label-primary btn-icon" @click="toggleText">
<button class="btn btn-label-primary btn-icon" :class="{ active: props.isPushed }" @click="toggleText">
<i :class="buttonClass"></i>
</button>
</template>
<script setup>
import { ref, watch, defineProps, defineEmits, watchEffect } from 'vue';
import { ref, watch, defineEmits, watchEffect } from 'vue';
const props = defineProps({
isToggleEnabled: {
type: Boolean,
@ -14,18 +14,22 @@
type: Boolean,
required: false,
},
isPushed: {
type: Boolean,
required: false,
},
});
const emit = defineEmits(["click"]);
const emit = defineEmits(['click']);
const buttonClass = ref('bx bx-edit-alt');
watchEffect(() => {
buttonClass.value = props.isActive ? 'bx bx-x' : 'bx bx-edit-alt';
});
const toggleText = (event) => {
const toggleText = event => {
//
if (props.isToggleEnabled) {
buttonClass.value = buttonClass.value === 'bx bx-edit-alt' ? 'bx bx-x' : 'bx bx-edit-alt';
}
emit("click", event); //
emit('click', event); //
};
const resetButton = () => {
buttonClass.value = 'bx bx-edit-alt';

View File

@ -1,6 +1,7 @@
<template>
<!-- 뒤로가기 -->
<button
v-if="canGoBack"
@click="goBack"
:disabled="!canGoBack"
:class="{ 'shifted': showButton }"
@ -26,7 +27,7 @@ const showButton = ref(false);
const canGoBack = ref(false);
const route = useRoute();
const router = useRouter();
const loginPage = "/login"; //
const loginPagePath = "/login"; //
//
const handleScroll = () => {
@ -50,7 +51,21 @@ const updateCanGoBack = () => {
const historyBack = router.options.history.state.back;
const previousPage = document.referrer;
canGoBack.value = !!historyBack && historyBack !== loginPage && !previousPage.includes(loginPage);
// URL
const getPath = (url) => {
try {
return new URL(url, window.location.origin).pathname; //
} catch {
return ""; // URL
}
};
const previousPath = getPath(previousPage);
// :
canGoBack.value = !!historyBack
&& getPath(historyBack) !== loginPagePath
&& !previousPath.startsWith(loginPagePath);
};
//

View File

@ -45,7 +45,7 @@ const props = defineProps({
}
});
const emit = defineEmits(['workTimeUpdated', 'leaveTimeUpdated', 'projectChangeComplete']);
const emit = defineEmits(['workTimeUpdated', 'leaveTimeUpdated', 'projectChangeComplete', 'update:pendingProjectChange']);
const workTime = ref(null);
const leaveTime = ref(null)
@ -171,7 +171,6 @@ const setWorkTime = async () => {
}).then(res => {
if (res.status === 200) {
todayCommuterInfo();
emit('workTimeUpdated', true);
}
});
@ -182,7 +181,8 @@ const setLeaveTime = async () => {
//
const address = await getLocation();
if (!address) {
if (!address && !leaveTime.value) {
//
if (!confirm('위치 정보를 가져오지 못했습니다. 위치 없이 퇴근 처리하시겠습니까?')) {
return;
@ -198,6 +198,7 @@ const setLeaveTime = async () => {
if (res.status === 200) {
todayCommuterInfo();
emit('leaveTimeUpdated');
emit('update:pendingProjectChange', null);
}
});
};
@ -209,10 +210,6 @@ watch(() => props.userId, async () => {
}
});
watch(() => props.checkedInProject, () => {
//
}, { deep: true });
onMounted(async () => {
await todayCommuterInfo();
});

View File

@ -4,7 +4,7 @@
<div class="row g-0">
<div class="col-3 border-end text-center" id="app-calendar-sidebar">
<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" @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">
{{ user.name }}
</p>
@ -13,6 +13,7 @@
:userId="user.id"
:checkedInProject="checkedInProject || {}"
:pendingProjectChange="pendingProjectChange"
@update:pendingProjectChange="pendingProjectChange = $event"
@workTimeUpdated="handleWorkTimeUpdate"
@leaveTimeUpdated="handleLeaveTimeUpdate"
ref="workTimeComponentRef"
@ -59,7 +60,7 @@
<div class="row my-2 d-flex align-items-center">
<div class="col-4">
<img :src="`${baseUrl}upload/img/profile/${commuter.profile}`"
class="rounded-circle me-2 w-px-50 h-px-50"
class="me-2 w-px-50 h-px-50 rounded-circle object-fit-cover"
@error="$event.target.src = '/img/icons/icon.png'">
<span class="fw-bold">{{ commuter.memberName }}</span>
@ -178,8 +179,34 @@ const handleWorkTimeUpdate = () => {
loadCommuters();
};
const handleLeaveTimeUpdate = () => {
todaysCommuter();
const handleLeaveTimeUpdate = async () => {
await todaysCommuter(); //
// null
const currentUserCommuter = commuters.value.find(c => c.MEMBERSEQ === user.value.id);
if (currentUserCommuter && !currentUserCommuter.COMMUTLVE) {
await projectStore.getMemberProjects();
if (projectStore.activeMemberProjectList.length > 0) {
const previousProject = projectStore.activeMemberProjectList.find(
p => commuters.value.some(c => c.MEMBERSEQ === user.value.id && c.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);
}
}
};
// (ProjectList )
@ -206,6 +233,11 @@ const handleProjectDrop = ({ event, targetProject }) => {
? { ...commuter, PROJCTNAM: targetProject.PROJCTNAM, PROJCTLVE: targetProject.PROJCTSEQ }
: commuter
);
// CommuterBtn (/ )
if (workTimeComponentRef.value && workTimeComponentRef.value.fetchWorkTime) {
workTimeComponentRef.value.fetchWorkTime();
}
};
//
@ -367,7 +399,7 @@ const loadCommuters = async () => {
//
const profileImg = document.createElement('img');
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';
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.onerror = () => { profileImg.src = '/img/icons/icon.png'; };
@ -431,12 +463,17 @@ watch(() => projectStore.selectedProject, (newProject) => {
if (newProject) {
selectedProject.value = newProject.PROJCTSEQ;
checkedInProject.value = newProject;
} else {
selectedProject.value = null;
checkedInProject.value = null;
}
});
//
const closeModal = () => {
isModalOpen.value = false;
visiblePopover.value = { type: null, index: null };
};
// MapPopover visible
@ -465,10 +502,11 @@ onMounted(async () => {
await todaysCommuter();
//
const storedProject = projectStore.getSelectedProject();
if (storedProject) {
selectedProject.value = storedProject.PROJCTSEQ;
checkedInProject.value = storedProject;
if (projectStore.activeMemberProjectList.length > 0) {
const initialProject = projectStore.getSelectedProject() || projectStore.activeMemberProjectList[0];
selectedProject.value = initialProject?.PROJCTSEQ || null;
projectStore.setSelectedProject(initialProject);
checkedInProject.value = initialProject;
}
datePickerStore.initDatePicker(

View File

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

View File

@ -41,6 +41,7 @@
<button class="ql-link">Link</button>
<button class="ql-image">Image</button>
<button class="ql-video">Video</button>
<button class="ql-blockquote">Blockquote</button>
<button class="ql-code-block">Code Block</button>
</div>
@ -54,8 +55,11 @@
<script setup>
import Quill from 'quill';
import 'quill/dist/quill.snow.css';
import { onMounted, ref, watch, defineEmits, defineProps } from 'vue';
import $api from '@api';
import { onMounted, ref, watch, defineEmits, defineProps } from 'vue';
import { useToastStore } from '@s/toastStore';
const toastStore = useToastStore();
const props = defineProps({
isAlert: {
@ -131,6 +135,38 @@
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
quillInstance.getModule('toolbar').addHandler('image', () => {

View File

@ -4,28 +4,37 @@
{{ title }}
<span v-if="isEssential" class="link-danger">*</span>
</label>
<div :class="isRow ? 'col-md-10' : 'col-md-12'" class="d-flex gap-2 align-items-center">
<select class="form-select" :id="name" v-model="selectData" :disabled="disabled" :style="isColor ? { color: selected } : {}" @blur="$emit('blur')">
<option v-for="(item, i) in data" :key="i" :value="isCommon ? item.value : i" :style="isColor ? { color: item.label } : {}">
{{ isCommon ? item.label : item }}
</option>
</select>
<div v-if="isBtn" class="ms-2">
<slot name="append"></slot>
<div :class="isRow ? 'col-md-10' : 'col-md-12'">
<div class="d-flex gap-2 align-items-center">
<select v-if="isColor && (!data || data.length === 0)" class="form-select" disabled>
<option>사용가능한 컬러가 없습니다</option>
</select>
<!-- 데이터가 있는 경우 원래 select 표시 -->
<select v-else class="form-select" :id="name" v-model="selectData" :disabled="disabled" :style="isColor ? { color: selected } : {}" @blur="$emit('blur')">
<option v-for="(item, i) in data" :key="i" :value="isCommon ? item.value : i" :style="isColor ? { color: item.label } : {}">
{{ isCommon ? item.label : item }}
</option>
</select>
<div v-if="isBtn" class="ms-2">
<slot name="append"></slot>
</div>
<div v-if="isColor && selected"
class="w-px-40 h-px-30"
:style="{backgroundColor: selected}">
</div>
<img v-if="isMbti && selected"
role="img"
class="w-px-30 h-px-40"
:src="`/img/mbti/${selected.toLowerCase()}.png`"
alt="MBTI image"/>
</div>
<div v-if="isColor && selected"
class="w-px-40 h-px-30"
:style="{backgroundColor: selected}">
</div>
<img v-if="isMbti && selected"
role="img"
class="w-px-30 h-px-40"
:src="`/img/mbti/${selected.toLowerCase()}.png`"
alt="MBTI image"/>
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div>
</div>
<div v-if="isAlert" class="invalid-feedback">{{ title }} 확인해주세요.</div>
</div>
</template>

View File

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

View File

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

View File

@ -1,18 +1,27 @@
<template>
<div class="col-md-6 col-lg-4 col-xl-4 order-0 mb-6">
<div class="card text-center h-100">
<!-- 더보기 버튼 -->
<div class="d-flex">
<router-link
:to="{ name: 'BoardList', query: { type: selectedBoard } }"
class="btn btn-primary mr-1 pe-1 ps-1 ms-auto my-auto h-50"
>
more
</router-link>
</div>
<div class="card-body">
<!-- 모달 본문 -->
<div class="modal-bod mt-3">
<div class="modal-body">
<!-- 버튼 영역 -->
<div class="btn-group mb-3" role="group">
<div class="btn-group mb-5" role="group">
<button
type="button"
class="btn"
:class="selectedBoard === 'notices' ? 'btn-primary' : 'btn-outline-primary'"
@click="changeBoard('notices')"
>
공지사항
공지
</button>
<button
type="button"
@ -20,7 +29,7 @@
:class="selectedBoard === 'general' ? 'btn-primary' : 'btn-outline-primary'"
@click="changeBoard('general')"
>
자유게시판
자유
</button>
<button
type="button"
@ -28,21 +37,25 @@
:class="selectedBoard === 'anonymous' ? 'btn-primary' : 'btn-outline-primary'"
@click="changeBoard('anonymous')"
>
익명게시판
익명
</button>
</div>
<!-- 게시글 미리보기 테이블 -->
<table class="table">
<thead>
<tr>
<!-- 익명게시판은 'nickname', 나머지는 'writer' -->
<th class="text-center" style="width: 20%;">
{{ selectedBoard === 'anonymous' ? 'nickname' : 'writer' }}
<!-- 익명게시판은 '닉네임', 나머지는 '작성자' -->
<th class="text-start">
<div class="ms-4">
{{ selectedBoard === 'anonymous' ? '닉네임' : '작성자' }}
</div>
</th>
<!-- 제목 헤더는 왼쪽 정렬 -->
<th class="text-start" style="width: 65%;">title</th>
<th class="text-center" style="width: 15%;">views</th>
</tr>
<th class="text-start" style="width: 65%;">
<div class="ms-4">
제목
</div>
</th>
</tr>
</thead>
<tbody>
<tr
@ -51,26 +64,31 @@
style="cursor: pointer;"
@click="goDetail(post.id, selectedBoard)"
>
<td class="text-center">
<td class="text-start nickname-ellipsis small">
<div class="ms-4">
{{ selectedBoard === 'anonymous' ? post.nickname : post.author }}
</div>
</td>
<td class="text-start">
<div>
<td class="text-start fs-6">
<div class="ms-4">
{{ truncateTitle(post.title) }}
<span v-if="post.commentCount" class="text-danger ml-1">
<span v-if="post.commentCount" class="text-danger ml-1 small">
[{{ 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 small"></i>
<i
v-if="post.hasAttachment.length > 0"
class="bi bi-paperclip ml-1"
class="bi bi-paperclip ml-1 small"
></i>
</div>
<div class="text-muted small">
<div class="text-muted small small">
{{ post.date }}
<span class="ms-1">
<i class="fa-regular fa-eye small me-1"></i>{{post.views}}
</span>
</div>
</div>
</td>
<td class="text-center">{{ post.views }}</td>
</tr>
<tr v-if="currentList.length === 0">
<td colspan="3" class="text-center text-muted">게시물이 없습니다.</td>
@ -78,15 +96,6 @@
</tbody>
</table>
</div>
<!-- 모달 푸터: 더보기 버튼 오른쪽 정렬 -->
<div class="modal-foote d-flex">
<router-link
:to="{ name: 'BoardList', query: { type: selectedBoard } }"
class="btn btn-primary mr-1 pe-1 ps-1 ms-auto my-auto h-50"
>
more
</router-link>
</div>
</div>
</div>
</div>
@ -119,97 +128,97 @@ const anonymousList = ref([]);
// computed
const currentList = computed(() => {
if (selectedBoard.value === 'notices') return noticeList.value;
if (selectedBoard.value === 'general') return freeList.value;
if (selectedBoard.value === 'anonymous') return anonymousList.value;
return [];
if (selectedBoard.value === 'notices') return noticeList.value;
if (selectedBoard.value === 'general') return freeList.value;
if (selectedBoard.value === 'anonymous') return anonymousList.value;
return [];
});
// : HH:mm, YYYY-MM-DD
const formatDate = dateString => {
const date = dayjs(dateString);
return date.isToday() ? date.format('HH:mm') : date.format('YYYY-MM-DD');
const date = dayjs(dateString);
return date.isToday() ? date.format('HH:mm') : date.format('YYYY-MM-DD');
};
// 14 ...
const truncateTitle = title => {
return title.length > 8 ? title.slice(0, 8) + '...' : title;
return title.length > 7 ? title.slice(0, 7) + '...' : title;
};
// ( 5)
const fetchNoticePosts = async () => {
try {
const { data } = await axios.get('board/notices', { params: { size: 5 } });
if (data?.data) {
noticeList.value = data.data.map(post => ({
id: post.id,
title: post.title,
date: formatDate(post.date),
rawDate: post.date,
views: post.cnt || 0,
commentCount: post.commentCount,
img: post.firstImageUrl,
author: post.author || '관리자',
nickname: post.nickname || '관리자',
hasAttachment: post.hasAttachment, //
}));
try {
const { data } = await axios.get('board/notices', { params: { size: 8 } });
if (data?.data) {
noticeList.value = data.data.map(post => ({
id: post.id,
title: post.title,
date: formatDate(post.date),
rawDate: post.date,
views: post.cnt || 0,
commentCount: post.commentCount,
img: post.firstImageUrl,
author: post.author || '관리자',
nickname: post.nickname || '관리자',
hasAttachment: post.hasAttachment, //
}));
}
} catch (error) {
}
} catch (error) {
}
};
// board/general ( 10 5)
const fetchGeneralPosts = async () => {
try {
const { data } = await axios.get('board/general', { params: { size: 10 } });
if (data?.data && data.data.list) {
const freePosts = [];
const anonymousPosts = [];
data.data.list.forEach(post => {
if (post.nickname) {
//
anonymousPosts.push({
id: post.id,
title: post.title,
date: formatDate(post.date),
img: post.firstImageUrl,
rawDate: post.date,
views: post.cnt || 0,
commentCount: post.commentCount,
nickname: post.nickname,
hasAttachment: post.hasAttachment, //
});
} else {
//
freePosts.push({
id: post.id,
title: post.title,
date: formatDate(post.date),
rawDate: post.date,
views: post.cnt || 0,
img: post.firstImageUrl,
commentCount: post.commentCount,
author: post.author || '익명',
hasAttachment: post.hasAttachment, //
try {
const { data } = await axios.get('board/general', { params: { size: 16 } });
if (data?.data && data.data.list) {
const freePosts = [];
const anonymousPosts = [];
data.data.list.forEach(post => {
if (post.nickname) {
//
anonymousPosts.push({
id: post.id,
title: post.title,
date: formatDate(post.date),
img: post.firstImageUrl,
rawDate: post.date,
views: post.cnt || 0,
commentCount: post.commentCount,
nickname: post.nickname,
hasAttachment: post.hasAttachment, //
});
} else {
//
freePosts.push({
id: post.id,
title: post.title,
date: formatDate(post.date),
rawDate: post.date,
views: post.cnt || 0,
img: post.firstImageUrl,
commentCount: post.commentCount,
author: post.author || '익명',
hasAttachment: post.hasAttachment, //
});
}
});
freeList.value = freePosts.slice(0, 8);
anonymousList.value = anonymousPosts.slice(0, 8);
}
});
freeList.value = freePosts.slice(0, 5);
anonymousList.value = anonymousPosts.slice(0, 5);
} catch (error) {
console.error(error);
}
} catch (error) {
console.error(error);
}
};
//
const changeBoard = type => {
selectedBoard.value = type;
selectedBoard.value = type;
};
// ( )
const goDetail = (id, boardType) => {
router.push({ name: 'BoardDetail', params: { id }, query: { type: boardType } });
router.push({ name: 'BoardDetail', params: { id }, query: { type: boardType } });
};
//
@ -219,12 +228,6 @@ fetchGeneralPosts();
<style scoped>
.table > :not(caption) > * > * {
padding: 0 !important;
}
.ml-1 {
margin-left: 0.25rem;
}
.mr-1{
margin-right: 0.25rem;
padding: 0 !important;
}
</style>

View File

@ -28,10 +28,30 @@
placeholder="장소"
v-model="eventPlace"
maxlength="20"
@input="handleChangeInput"
/>
<span v-if="noInputAlert" class="invalid-feedback d-block" style="padding-left: 5px">{{ noInputAlert }}</span>
</div>
<div class="mb-2">
<input type="time" class="form-control form-control-sm py-1" style="height: 25px; font-size: 12px" v-model="eventTime" />
<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
ref="timeInput"
type="time"
class="hidden-time-input"
style="height: 0%; font-size: 12px"
v-model="eventTime"
@input="handleChangeInput2"
/>
<span v-if="noInputAlert2" class="invalid-feedback d-block" style="padding-left: 5px">{{ noInputAlert2 }}</span>
</div>
<div class="text-end">
<button class="btn btn-primary btn-sm py-1" style="font-size: 12px; height: 25px; line-height: 1" @click="handleSubmit">
@ -70,6 +90,16 @@
const selectedEventType = ref(null);
const eventPlace = ref('');
const eventTime = ref('');
const noInputAlert = ref(null);
const noInputAlert2 = ref(null);
const timeInput = ref(null);
const focusPicker = () => {
if (timeInput.value) {
timeInput.value.showPicker(); // ( )
timeInput.value.focus(); //
}
};
const eventTypes = [
{ type: 'birthdayParty', code: '300203', title: '생일파티' },
@ -79,8 +109,6 @@
];
const getEventTitle = type => {
console.log('type: ', type);
console.log('event.type: ', eventTypes);
return eventTypes.find(event => event.code === type)?.title || '';
};
@ -99,22 +127,50 @@
}
} else {
selectedEventType.value = event.code;
noInputAlert.value = '';
noInputAlert2.value = '';
}
};
const handleSubmit = () => {
if (!eventPlace.value || !eventTime.value) {
alert('장소와 시간을 모두 입력해주세요');
return;
if (isValid()) {
emit('insert', {
date: props.selectedDate,
code: selectedEventType.value,
title: getEventTitle(selectedEventType.value),
place: eventPlace.value.trim(),
time: eventTime.value,
});
}
};
//
const isValid = () => {
let isValid = true;
if (!eventPlace.value.trim()) {
noInputAlert.value = '장소를 입력해 주세요';
isValid = false;
} else {
noInputAlert.value = '';
}
emit('insert', {
date: props.selectedDate,
code: selectedEventType.value,
title: getEventTitle(selectedEventType.value),
place: eventPlace.value,
time: eventTime.value,
});
if (!eventTime.value) {
noInputAlert2.value = '시간을 입력해 주세요';
isValid = false;
} else {
noInputAlert2.value = '';
}
return isValid;
};
const handleChangeInput = () => {
noInputAlert.value = null;
};
const handleChangeInput2 = () => {
noInputAlert2.value = null;
};
const resetForm = () => {

View File

@ -1,8 +1,8 @@
<template>
<div class="ps-2">
<span class="d-flex align-items-center g-2 font_767"><i class="bx bx-map pe-1"></i>{{ place }}</span>
<div class="ps-2" style="font-size: 13px">
<span class="d-flex align-items-center g-2 font_767"><i class="bx bxs-map pe-1"></i>{{ place }}</span>
<span class="d-flex align-items-center g-2 font_767"
><i class="bx bx-time-five pe-1"></i>{{ $common.dateFormatter(time, 'T') }}</span
><i class="bx bxs-time-five pe-1"></i>{{ $common.dateFormatter(time, 'T') }}</span
>
</div>
</template>

View File

@ -5,16 +5,23 @@
<div class="card-body">
<img
v-if="user"
:src="`${baseUrl}upload/img/profile/${user.profile}`"
:src="`${profileImgUrl}profile/${user.profile}`"
alt="Profile Image"
class="w-px-50 h-px-50 rounded-circle"
class="w-px-50 h-px-50 rounded-circle profile-img"
@error="$event.target.src = '/img/icons/icon.png'"
/>
<p class="mt-2 fw-bold">
{{ user.name }}
</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
:categoryList="categoryList"
@ -25,6 +32,7 @@
:dinnerList="dinnerList"
:teaTimeList="teaTimeList"
:workShopList="workShopList"
@handle-click-vacation="handleClickVacation"
/>
</div>
</div>
@ -40,6 +48,7 @@
class="flatpickr-calendar-only"
>
</full-calendar>
<input ref="calendarDatepicker" type="text" class="d-none" />
</div>
</div>
</div>
@ -60,11 +69,15 @@
</template>
<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 { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useProjectStore } from '@/stores/useProjectStore';
import { useToastStore } from '@s/toastStore';
import { useWeatherStore } from '@/stores/useWeatherStore';
import { useDatePicker } from '@/stores/useDatePicker';
import { storeToRefs } from 'pinia';
import router from '@/router';
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
@ -76,17 +89,24 @@
import '@/assets/css/app-calendar.css';
const baseUrl = import.meta.env.VITE_DOMAIN;
const profileImgUrl = import.meta.env.VITE_SERVER_IMG_URL;
const user = ref({});
const userStore = useUserInfoStore();
const projectStore = useProjectStore();
const weatherStore = useWeatherStore();
const datePickerStore = useDatePicker();
const { dailyWeatherList } = storeToRefs(weatherStore);
const dayjs = inject('dayjs');
const fullCalendarRef = ref(null);
const workTimeComponentRef = ref(null);
const calendarEvents = ref([]);
const calendarDatepicker = ref(null);
//const dailyWeatherList = ref([]);
const selectedProject = ref(null);
const checkedInProject = ref(null);
const pendingProjectChange = ref(null);
//
const showModal = ref(false);
@ -134,7 +154,7 @@
//
const holidayEvents = calendarEvents.value.filter(event => event.classNames?.includes('holiday-event'));
calendarEvents.value = [...holidayEvents];
calendarEvents.value = [...holidayEvents, ...dailyWeatherList.value];
//
if (res?.memberBirthdayList?.length) {
@ -156,7 +176,7 @@
monthBirthdayPartyList.value = [];
monthDinnerList.value = [];
monthTeaTimeList.value = [];
monthTeaTimeList.value = [];
monthWorkShopList.value = [];
if (res?.eventList?.length) {
res.eventList.forEach(item => {
@ -292,7 +312,7 @@
//
const isSelectableDate = date => {
const checkDate = dayjs(date);
const isWeekend = checkDate.day() === 0 || checkDate.day() === 6;
const isWeekend = checkDate.day() === 0 || checkDate.day() === 6; //
//
const isHoliday = calendarEvents.value.some(
event =>
@ -310,6 +330,9 @@
// (, , )
if (!isSelectableDate(cellDate)) {
classes.push('fc-day-sat-sun');
} else {
//
classes.push('clickable');
}
return classes;
@ -318,7 +341,7 @@
//
let todayEL = null;
const handleDateClick = info => {
if (isSelectableDate(info)) {
if (isSelectableDate(info.date)) {
if ($common.isToday(info.date)) {
//
todayEL = info.dayEl;
@ -333,6 +356,11 @@
useFilterEventList(month, day);
};
//
const colorToday = e => {
if (todayEL != null && !todayEL.classList.contains('fc-day-today')) todayEL.classList.add('fc-day-today');
};
//
const handleMouseDown = (date, jsEvent) => {
if (showModal.value) showModal.value = false;
@ -490,10 +518,13 @@
selectAllow: selectInfo => isSelectableDate(selectInfo.start),
dateClick: handleDateClick,
dayCellDidMount: arg => {
//
addWeatherInfo(arg);
const dateCell = arg.el;
//
dateCell.addEventListener('mousedown', e => {
if (!isSelectableDate(arg.date)) return; //
const date = $common.dateFormatter(arg.date, 'YMD');
handleMouseDown(date, e);
});
@ -520,6 +551,53 @@
},
});
//
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 => {
handleCloseModal();
};
//
const handleClickVacation = () => {
router.push('/vacation');
};
// ( )
watch(
() => fullCalendarRef.value?.getApi().currentData.viewTitle,
@ -528,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 () => {
await userStore.userInfo();
user.value = userStore.user;
@ -551,35 +678,37 @@
await fetchEventList(param);
useFilterEventList(month, day);
//
// document.addEventListener('click', e => {
// if (showModal.value && !e.target.closest('.event-modal') && !e.target.closest('.fc-daygrid-day')) {
// showModal.value = false;
// }
// });
//
window.addEventListener('wheel', handleWheelEvent);
window.addEventListener('click', colorToday);
datePickerStore.initDatePicker(fullCalendarRef, async (year, month, options) => {
//
await fetchData();
});
});
</script>
<style>
.fc-h-event {
<style scoped>
::v-deep(.fc-h-event) {
background-color: transparent;
}
.event-modal {
::v-deep(.event-modal) {
padding: 8px;
border: 1px solid #ddd;
}
.event-icon-select:hover {
::v-deep(.event-icon-select:hover) {
transform: scale(1.1);
transition: transform 0.2s;
}
/* 이벤트 모달 노출 시 텍스트 선택 방지 */
.fc-daygrid-day {
::v-deep(.fc-daygrid-day) {
user-select: none;
}
.fc-daygrid-day-events {
::v-deep(.fc-daygrid-day-events) {
display: flex;
flex-wrap: wrap;
justify-content: center;
@ -587,4 +716,9 @@
align-items: center;
text-align: center !important; */
}
/* 공휴일만 가로로 넗게 나오게 */
::v-deep(.fc-daygrid-event-harness:has(.holiday-event)) {
width: 100% !important;
}
</style>

View File

@ -10,6 +10,8 @@
(category.CMNCODVAL === 300205 && teaTimeList?.length) ||
(category.CMNCODVAL === 300206 && workShopList?.length)
"
@click="category.CMNCODVAL == 300202 ? $emit('handleClickVacation') : ''"
:class="category.CMNCODVAL == 300202 ? 'pointer' : ''"
class="border border-2 mt-3 card p-2"
>
<div class="row g-2 position-relative">
@ -22,18 +24,13 @@
/>
</div>
</div>
<div
v-if="category.CMNCODVAL === 300201 || category.CMNCODVAL === 300202"
class="col-9 mx-0 px-0 d-flex align-items-center"
>
<div class="col-9 mx-0 px-0 d-flex align-items-center">
<template v-if="category.CMNCODVAL === 300201">
<MainMemberProfile :members="birthdayList" :baseUrl="baseUrl" />
</template>
<template v-if="category.CMNCODVAL === 300202">
<MainMemberProfile :members="vacationList" :baseUrl="baseUrl" />
</template>
</div>
<div v-else class="col-9 mx-0 px-0 d-flex align-items-center">
<template v-if="category.CMNCODVAL === 300203">
<MainEventBoard :place="birthdayPartyList[0].LOCEVTPLC" :time="birthdayPartyList[0].LOCEVTTME" />
</template>
@ -105,6 +102,8 @@
type: Array,
},
});
defineEmits(['handleClickVacation']);
</script>
<style scoped>

View File

@ -2,12 +2,12 @@
<div class="ms-2" style="flex: 1">
<ul class="row gx-1 mb-0 list-inline">
<li class="col-4 me-0" v-for="(member, index) in members" :key="index">
<div class="ratio ratio-1x1 mb-0 profile-list">
<div class="ratio ratio-1x1 mb-0">
<img
:src="`${baseUrl}upload/img/profile/${member.MEMBERPRF}`"
:src="`${profileImgUrl}profile/${member.MEMBERPRF}`"
:style="`border-color: ${member.usercolor} !important;`"
alt="User Profile"
class="rounded-circle border border-2"
class="rounded-circle border border-2 profile-img"
@error="$event.target.src = '/img/icons/icon.png'"
/>
</div>
@ -16,17 +16,17 @@
</div>
</template>
<script>
export default {
props: {
members: {
type: Array,
required: true,
},
baseUrl: {
type: String,
required: true,
},
<script setup>
const props = defineProps({
members: {
type: Array,
required: true,
},
};
baseUrl: {
type: String,
required: true,
},
});
const profileImgUrl = import.meta.env.VITE_SERVER_IMG_URL;
</script>

View File

@ -1,43 +1,8 @@
<template>
<div class="col-md-6 col-lg-4 col-xl-4 order-0 mb-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between">
<div class="card-title mb-0">
<h5 class="mb-1 me-2">투표진행</h5>
</div>
</div>
<div class="card-body">
<ul class="p-0 m-0">
<li class="d-flex mb-1" v-for="item in voteList" :key="item.LOCVOTSEQ">
<div class="d-flex w-100 flex-wrap align-items-center justify-content-between gap-2">
<div class="me-2 mb-3">
<small class="card-subtitle">{{ item.localVote.formatted_LOCVOTEDT }}</small>
<div class="d-flex flex-wrap align-items-center">
<div class="avatar flex-shrink-0 me-1">
<img
style="cursor: auto;"
class="rounded-circle user-avatar"
:src="getProfileImage(item.localVote.MEMBERPRF)"
alt="최초 작성자"
:style="{ borderColor: item.localVote.usercolor }"
@error="setDefaultImage"
/>
</div>
<div class="timeline-event ps-1" style="cursor: pointer;" @click="goVoteList()" >
<div class="timeline-header ">
<small ><strong>{{ truncateTitle(item.localVote.LOCVOTTTL) }}</strong></small>
</div>
<small class="d-flex align-items-center lh-1 me-4 mb-4 mb-sm-0"><span class="badge badge-dot text-bg-warning me-1">
</span> {{ getDaysAgo(item.localVote.formatted_LOCVOTEDT) }}</small>
</div>
</div>
</div>
</div>
</li>
</ul>
</div>
<!-- 모달 푸터: 더보기 버튼 오른쪽 정렬 -->
<div class="modal-foote d-flex">
<!-- 더보기 버튼-->
<div class="d-flex">
<router-link
to="/voteboard"
class="btn btn-primary mr-1 pe-1 ps-1 ms-auto my-auto h-50"
@ -45,15 +10,100 @@
more
</router-link>
</div>
<div class="card-header d-flex justify-content-between">
<div class="card-title mb-0">
<h5 class="mb-1 me-2">투표진행</h5>
</div>
</div>
<div class="card-body" v-if="voteListData.length > 0">
<ul class="p-0 m-0">
<li class="d-flex mb-1" v-for="item in voteListData" :key="item.LOCVOTSEQ">
<div class="d-flex w-100 flex-wrap align-items-center justify-content-between gap-2">
<div class="me-2 mb-3">
<div class="text-muted small">{{ item.localVote.formatted_LOCVOTRDT }}</div>
<div class="d-flex flex-wrap align-items-center">
<div class="avatar flex-shrink-0 me-1">
<img
style="cursor: auto;"
class="rounded-circle user-avatar object-fit-cover"
:src="getProfileImage(item.localVote.MEMBERPRF)"
alt="최초 작성자"
:style="{ borderColor: item.localVote.usercolor }"
@error="setDefaultImage"
/>
</div>
<div class="timeline-event ps-1" style="cursor: pointer;" @click.stop="openModal(item.localVote.LOCVOTSEQ)" >
<div class="timeline-header ">
<small ><strong>{{ truncateTitle(item.localVote.LOCVOTTTL) }}</strong></small>
</div>
<small class="d-flex align-items-center lh-1 me-4 mb-4 mb-sm-0"
:style="{ color: getDaysAgo(item.localVote.formatted_LOCVOTEDT) == '금일 종료' ? 'red' : '' }">
{{getDaysAgo(item.localVote.formatted_LOCVOTEDT)}}({{item.localVote.total_voted}}/{{ item.localVote.total_votable }})
</small>
</div>
</div>
</div>
</div>
</li>
</ul>
</div>
<div class="card-body" v-else>
진행중인 투표가 없습니다.
</div>
</div>
</div>
<!--투표 모달 -->
<CenterModal :display="isModalOpen" @close="closeModal">
<template #title> 투표 하기 </template>
<template #body>
<div>
<vote-list
:key="voteListKey"
:data="selectVoteDate"
@checkedNames="checkedNames"
@addContents="addContents"
@endVoteId="endVoteId"
@voteDelete="voteDelete"
/>
</div>
</template>
<template #footer>
<BackButton @click="closeModal" />
</template>
</CenterModal>
</template>
<script setup>
import router from '@/router';
import $api from '@api';
import { onMounted, ref } from 'vue';
import CenterModal from '@c/modal/CenterModal.vue';
import BackButton from '@c/button/BackBtn.vue';
import voteList from '@c/voteboard/voteCardList.vue';
import { useToastStore } from '@s/toastStore';
const toastStore = useToastStore();
const currentPage = ref(1);
const voteset = ref(0);
const voteListData= ref([]);
const voteListKey = ref(0); //
//
const isModalOpen = ref(false);
const selectVoteDate = ref([]);
//
const openModal = async (id) => {
isModalOpen.value = true;
if(id){
const selectData = voteListData.value.filter((item) => item.localVote.LOCVOTSEQ === id);
selectVoteDate.value = selectData;
}
};
//
const closeModal = () => {
isModalOpen.value = false;
voteListKey.value++;
};
//
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const defaultProfile = "/img/icons/icon.png";
@ -64,9 +114,6 @@ const setDefaultImage = (event) => {
event.target.src = defaultProfile;
};
const currentPage = ref(1);
const voteset = ref(0);
const voteList= ref([]);
onMounted(() => {
getvoteList();
});
@ -74,22 +121,94 @@ onMounted(() => {
//
const getvoteList = () => {
$api.get('vote/getVoteList',{
//
params:
{
page: 1
,voteset:'2' //
,myVote:'2' //
}
}).then(res => {
voteList.value = res.data.data.list;
voteList.value = res.data.data.list.slice(0, 5);
})
};
params:
{
page: 1
,voteset:'2' //
,myVote:'2' //
}
}).then(res => {
voteListData.value = res.data.data.list;
voteListData.value = res.data.data.list.slice(0, 6);
})
};
//
const checkedNames = (numList) => {
$api.post('vote/insertCheckedNums',{
checkedList :numList
,votenum : numList[0].LOCVOTSEQ
}).then((res)=>{
if(res.data.status === 'OK'){
toastStore.onToast('투표가 완료되었습니다.', 's');
isModalOpen.value = false;
getvoteList();
voteListKey.value++;
}
})
}
//
const addContents = (itemList, voteId) => {
$api.post('vote/insertWord',{
itemList :itemList
,voteId :voteId
}).then((res)=>{
if(res.data.status === 'OK'){
toastStore.onToast('항목이 등록되었습니다.', 's');
getvoteList();
const updatedVote = selectVoteDate.value.find(vote => vote.localVote.LOCVOTSEQ === voteId);
if (updatedVote) {
if (!updatedVote.voteDetails) {
updatedVote.voteDetails = [];
}
const maxSeq = updatedVote.voteDetails.reduce((max, item) => {
return item.VOTDETSEQ > max ? item.VOTDETSEQ : max;
}, 0);
// voteDetails
itemList.forEach(item => {
updatedVote.voteDetails.push({
VOTDETSEQ: maxSeq + 1,
LOCVOTSEQ: voteId,
LOCVOTCON: item.content,
LOCVOTLIK: item.url,
VOTE_COUNT: 0,
yesvote: 0
});
});
}
}
})
}
//
const endVoteId = (endVoteId) => {
if(confirm('투표를 종료하시겠습니까?')){
$api.patch('vote/updateEndData',{
endVoteId :endVoteId
}).then((res)=>{
if(res.data.status === 'OK'){
getvoteList();
isModalOpen.value = false;
}
})
}
}
//
const voteDelete =(id) =>{
if(confirm('투표를 삭제하시겠습니까?')){
$api.patch('vote/updateDeleteData',{
deleteVoteId :id
}).then((res)=>{
if(res.data.status === 'OK'){
toastStore.onToast('투표가 삭제되었습니다.', 's');
getvoteList();
isModalOpen.value = false;
}
})
}
}
// 14 ...
const truncateTitle = title => {
return title.length > 10 ? title.slice(0, 10) + '...' : title;
return title.length > 10 ? title.slice(0, 10) + '...' : title;
};
//
@ -97,7 +216,9 @@ const goVoteList = () =>{
router.push({
path: '/voteboard',
query: {
voteset: '2',
voteset: '2' //
,myVote:'2' //
,id:id
}
});
}
@ -105,12 +226,11 @@ const goVoteList = () =>{
const getDaysAgo = (dateString) => {
const inputDate = new Date(dateString); // Date
const today = new Date(); //
// ( )
const timeDiff = today - inputDate;
const dayDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24))+1; //
// ""
const input = new Date(inputDate.getFullYear(), inputDate.getMonth(), inputDate.getDate());
const now = new Date(today.getFullYear(), today.getMonth(), today.getDate());
const timeDiff = now - input;
const dayDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
//
if (dayDiff === 0) return "금일 종료";
return `종료 ${Math.abs(dayDiff)}일 전`;
@ -120,6 +240,5 @@ const getDaysAgo = (dateString) => {
<style scoped>
.user-avatar {
border: 3px solid;
padding: 0.1px;
}
</style>

View File

@ -1,36 +1,8 @@
<template>
<div class="col-md-6 col-lg-4 col-xl-4 order-0 mb-6">
<div class="card h-100">
<div class="card-header d-flex justify-content-between">
<div class="card-title mb-0">
<h5 class="mb-1 me-2">용어집</h5>
</div>
</div>
<div class="card-body">
<ul class="p-0 m-0" v-for="item in wordList" :key="item.WRDDICSEQ">
<small class="card-subtitle">{{$common.dateFormatter(item.lastEditor.updatedAt)}}</small>
<li class="d-flex mb-1">
<div class="avatar flex-shrink-0 me-1" >
<img
style="cursor: auto;"
class="rounded-circle user-avatar"
:src="getProfileImage(item.lastEditor.profileImage)"
alt="최종 작성자"
:style="{ borderColor: item.lastEditor.color }"
@error="setDefaultImage"
/>
</div>
<div class="timeline-event ps-1" style="cursor: pointer;" @click="goWordList(item.WRDDICCAT,item.WRDDICTTL)">
<div class="timeline-header">
<small class="text-primary text-uppercase">{{ item.category }}</small>
</div>
<h6 class="my-50">{{truncateTitle(item.WRDDICTTL) }}</h6>
</div>
</li>
</ul>
</div>
<!-- 모달 푸터: 더보기 버튼 오른쪽 정렬 -->
<div class="modal-foote d-flex ">
<!-- 더보기 버튼 -->
<div class="d-flex ">
<router-link
to="/wordDict"
class="btn btn-primary mr-1 pe-1 ps-1 ms-auto my-auto h-50"
@ -38,6 +10,43 @@
more
</router-link>
</div>
<div class="card-header d-flex justify-content-between">
<div class="card-title mb-0">
<h5 class="mb-1 me-2">용어집</h5>
</div>
</div>
<div class="card-body" v-if="wordList.length > 0">
<ul class="p-0 m-0" v-for="item in wordList" :key="item.WRDDICSEQ">
<li class="d-flex align-items-center mb-1">
<!-- 프로필 이미지 -->
<div class="avatar flex-shrink-0 me-2 d-flex align-items-center">
<img
class="rounded-circle user-avatar object-fit-cover"
:src="getProfileImage(item.lastEditor.profileImage)"
alt="최종 작성자"
:style="{ borderColor: item.lastEditor.color }"
@error="setDefaultImage"
/>
</div>
<!-- 텍스트 영역 -->
<div class="timeline-event ps-1 flex-grow-1" style="cursor: pointer;" @click="goWordList(item.WRDDICCAT,item.WRDDICTTL)">
<div class="timeline-header">
<small class="text-primary text-uppercase">{{ item.category }}</small>
</div>
<h6 class="my-50 d-flex align-items-center">
{{ truncateTitle(item.WRDDICTTL) }}
</h6>
<div class="text-muted small">{{$common.dateFormatter(item.lastEditor.updatedAt)}}</div>
</div>
</li>
</ul>
</div>
<div class="card-body" v-else >
등록된 용어가 없습니다.
</div>
</div>
</div>
</template>
@ -73,7 +82,7 @@ const getwordList = (searchKeyword='', indexKeyword='', category='') => {
searchKeyword : searchKeyword,
indexKeyword :indexKeyword,
category : category,
pageNum:5
pageNum:6
}
})
.then(res => {
@ -112,7 +121,7 @@ const goWordList = (category, indexKeyword) => {
path: '/wordDict',
query: {
indexKeyword: firstChar,
category: category
category: category,
}
});
};
@ -126,6 +135,5 @@ return title.length > 25 ? title.slice(0, 25) + '...' : title;
<style scoped>
.user-avatar {
border: 3px solid;
padding: 0.1px;
}
</style>

View File

@ -13,19 +13,27 @@
<img
:src="`${imgURL}profile/${member.MEMBERPRF}`"
alt="Profile Image"
class="img-fluid mx-auto d-block"
class="img-thumbnail mx-auto d-block"
style="max-height: 140px"
@error="$event.target.src = '/img/icons/icon.png'"
/>
</div>
<div class="col-7">
<!-- 날짜 -->
<div class="d-flex flex-sm-row align-items-center pb-2">
<div class="">{{ member.MEMBERNAM }}</div>
<div class="font-bold">{{ member.MEMBERNAM }}</div>
<div class="ms-2">
({{ member.MBTI }})
<img
role="img"
class="w-px-30 h-px-40"
:src="`/img/mbti/${member.MBTI.toLowerCase()}.png`"
alt="MBTI image"
/>
</div>
</div>
<!-- 참여자 -->
<div class="d-flex flex-sm-row align-items-center pb-2">
<i class="bx bxs-envelope"></i>
<div class="ms-2">{{ member.MEMBERIDS }}@local-host.co.kr</div>
<i class="bx bx-id-card"></i>
<div class="ms-2">{{ member.MEMBERIDS }}</div>
</div>
<div class="d-flex flex-sm-row align-items-center pb-2">
<i class="bx bxs-phone"></i>
@ -33,7 +41,7 @@
</div>
<div class="d-flex flex-sm-row align-items-center pb-2">
<i class="bx bx-calendar"></i>
<div class="ms-2">{{ $common.dateFormatter(member.MEMBERRDT) }}</div>
<div class="ms-2">{{ $common.dateFormatter(member.MEMBERCDT) }}</div>
</div>
</div>
<div class="col-2 d-flex align-items-center">
@ -42,14 +50,15 @@
<label class="switch"
><input
type="checkbox"
:checked="checked"
@change="handleRegisterMember(member.MEMBERSEQ)" /><span class="slider round"></span
:checked="member.checked"
@click="handleRegisterMember($event, member)" />
<span class="slider round"></span
></label>
</div>
<button
class="btn-close btn-close-sm"
style="position: absolute; top: 10px; right: 10px"
@click="handleRejectMember(member.MEMBERSEQ)"
@click="handleRejectMember(member)"
></button>
</div>
</div>
@ -68,30 +77,37 @@
import $api from '@api';
const memberList = ref([]);
const checked = ref(false);
const toast = useToastStore();
const imgURL = import.meta.env.VITE_SERVER_IMG_URL;
// api
const fetchRegisterMemberList = async () => {
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
const handleRegisterMember = async memberSeq => {
const { data } = await $api.post('main/registerMember', { memberSeq: memberSeq });
const handleRegisterMember = async (e, member) => {
e.preventDefault();
const { data } = await $api.post('main/registerMember', { memberSeq: member.MEMBERSEQ });
if (data?.data) {
member.checked = true;
toast.onToast(data.data, 's');
fetchRegisterMemberList();
}
};
// api
const handleRejectMember = async memberSeq => {
const handleRejectMember = async member => {
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) {
toast.onToast(data.data, 's');
fetchRegisterMemberList();

View File

@ -56,6 +56,7 @@
:value="color"
:data="colorList"
@update:data="color = $event"
:is-alert="colorAlert"
/>
<div class="mb-2 row">
@ -166,9 +167,11 @@
const endDay = ref('');
const description = ref('');
const nameAlert = ref(false);
const colorAlert = ref(false);
const addressAlert = ref(false);
const startDayAlert = ref(false);
const startDateInput = ref(null);
const endDateInput = ref(null);
@ -257,7 +260,9 @@
const formReset = () => {
name.value = '';
color.value = colorList.value[0].value;
if (colorList.value && colorList.value.length > 0) {
color.value = colorList.value[0].value;
}
addressData.value = {
postcode: '',
address: '',
@ -317,7 +322,11 @@
startDayAlert.value = startDay.value.trim() === '';
addressAlert.value = addressData.value.address.trim() === '';
if (nameAlert.value || startDayAlert.value || addressAlert.value) {
if (!colorList.value || colorList.value.length === 0) {
colorAlert.value = true;
}
if (nameAlert.value || startDayAlert.value || addressAlert.value || colorAlert.value) {
return;
}

View File

@ -3,9 +3,9 @@
<div class="text-center">
<label
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"
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>
@ -25,6 +25,7 @@
@update:alert="idAlert = $event"
@blur="checkIdDuplicate"
:value="id"
@keypress="noSpace"
/>
<span v-if="idError" class="invalid-feedback d-block">{{ idError }}</span>
@ -37,6 +38,7 @@
@update:data="password = $event"
@update:alert="passwordAlert = $event"
:value="password"
@keypress="noSpace"
/>
<span v-if="passwordError" class="invalid-feedback d-block">{{ passwordError }}</span>
@ -49,6 +51,7 @@
@update:data="passwordcheck = $event"
@update:alert="passwordcheckAlert = $event"
:value="passwordcheck"
@keypress="noSpace"
/>
<span v-if="passwordcheckError" class="invalid-feedback d-block">{{ passwordcheckError }}</span>
@ -82,6 +85,7 @@
@update:data="name = $event"
@update:alert="nameAlert = $event"
:value="name"
@keypress="noSpace"
class="me-2 w-50"
/>
@ -95,6 +99,7 @@
:is-color="true"
:data="colorList"
@update:data="handleColorUpdate"
:is-alert="colorAlert"
class="w-50"
/>
</div>
@ -143,13 +148,12 @@
name="phone"
:isEssential="true"
:is-alert="phoneAlert"
@update:data="phone = $event"
@update:alert="phoneAlert = $event"
@blur="checkPhoneDuplicate"
:maxlength="11"
:value="phone"
@keypress="onlyNumber"
@input="inputEvent"
/>
<span v-if="phoneError" class="invalid-feedback d-block">{{ phoneError }}</span>
@ -205,6 +209,7 @@
const passwordcheckErrorAlert = ref(false); //
const pwhintResAlert = ref(false);
const nameAlert = ref(false);
const colorAlert = ref(false);
const birthAlert = ref(false);
const addressAlert = ref(false);
const phoneAlert = ref(false);
@ -213,6 +218,10 @@
const toastStore = useToastStore();
const noSpace = (e) => {
if (e.key === ' ') e.preventDefault();
};
//
const profileValid = (size, type) => {
const maxSize = 5 * 1024 * 1024;
@ -326,6 +335,12 @@
}
};
const inputEvent = (e) => {
const newValue = e.target.value.replace(/\D/g, ''); //
e.target.value = newValue; //
phone.value = newValue; // Vue
};
watch(id, (newValue) => {
if (newValue && newValue.length >= 4) {
idError.value = '';
@ -337,6 +352,7 @@
});
watch(password, (newValue) => {
if (newValue && newValue.length >= 4) {
passwordErrorAlert.value = false;
passwordError.value = '';
@ -359,7 +375,6 @@
}
});
//
const handleSubmit = async () => {
await checkColorDuplicate();
@ -373,6 +388,10 @@
addressAlert.value = address.value.trim() === '';
phoneAlert.value = phone.value.trim() === '';
if (!colorList.value || colorList.value.length === 0) {
colorAlert.value = true;
}
//
if (id.value && id.value.length < 4) {
idErrorAlert.value = true;
@ -386,8 +405,10 @@
} else {
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;
} else {
phoneAlert.value = false;
@ -402,6 +423,7 @@
profilAlert.value = false;
}
if (
profilAlert.value ||
idAlert.value ||
@ -416,19 +438,20 @@
addressAlert.value ||
phoneAlert.value ||
phoneErrorAlert.value ||
colorAlert.value ||
colorErrorAlert.value
) {
return;
}
const formData = new FormData();
formData.append('memberIds', id.value);
formData.append('memberPwd', password.value);
formData.append('memberIds', id.value.trim());
formData.append('memberPwd', password.value.trim());
formData.append('memberPwh', pwhint.value);
formData.append('memberPwr', pwhintRes.value);
formData.append('memberNam', name.value);
formData.append('memberPwr', pwhintRes.value.trim());
formData.append('memberNam', name.value.trim());
formData.append('memberArr', address.value);
formData.append('memberDtl', detailAddress.value);
formData.append('memberDtl', detailAddress.value.trim());
formData.append('memberZip', postcode.value);
formData.append('memberBth', birth.value);
formData.append('memberTel', phone.value);

View File

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

View File

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

View File

@ -4,7 +4,7 @@
<h5 class="card-title mb-1">
<div class="list-unstyled users-list d-flex align-items-center gap-1">
<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}`"
:style="`border-color: ${data.localVote.usercolor} !important;`"
@error="$event.target.src = '/img/icons/icon.png'"
@ -33,7 +33,7 @@
</div>
</div>
</h5>
<h5 class="mb-1">{{ data.localVote.LOCVOTTTL }}
<h5 class="mb-0">{{ data.localVote.LOCVOTTTL }}
<i v-if="yesVotetotal != '0'" class="bx bxs-check-circle link-success"></i>
</h5>
<small >{{ data.localVote.formatted_LOCVOTRDT }} ~ {{ data.localVote.formatted_LOCVOTEDT }}</small>

View File

@ -3,7 +3,7 @@
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap border-top-0 p-0">
<div class="d-flex flex-wrap align-items-center">
<ul class="list-unstyled users-list d-flex align-items-center avatar-group m-0 me-2">
<ul class="list-unstyled users-list d-flex align-items-center avatar-group">
<vote-complete-user-list-card :data="data"/>
</ul>
</div>

View File

@ -7,9 +7,9 @@
:aria-label="data.MEMBERSEQ"
:data-bs-original-title="getTooltipTitle(data)">
<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}`"
:style="`border-color: ${data.usercolor} !important;`"
:style="`border-color: ${data.usercolor} !important; width: 90%; height: 90%;`"
@error="$event.target.src = '/img/icons/icon.png'"
alt="user"
/>

View File

@ -3,7 +3,7 @@
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap border-top-0 p-0">
<div class="d-flex flex-wrap align-items-center">
<ul class="list-unstyled users-list d-flex align-items-center avatar-group m-0 me-2">
<ul class="list-unstyled users-list d-flex align-items-center avatar-group ">
<vote-in-complete-user-list-card :data="data" />
</ul>
</div>

View File

@ -7,9 +7,9 @@
:aria-label="data.MEMBERSEQ"
:data-bs-original-title="getTooltipTitle(data)">
<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}`"
:style="`border-color: ${data.usercolor} !important;`"
:style="`border-color: ${data.usercolor} !important; width: 90%; height: 90%;`"
@error="$event.target.src = '/img/icons/icon.png'"
alt="user"
/>

View File

@ -2,7 +2,7 @@
<div class="d-flex align-items-start mt-3">
<!--투표한 사람 목록 -->
<div class="d-flex align-items-center gap-2 flex-wrap">
<i class='bx bxs-user-check link-info fa-3x'></i>
<i class='bx bxs-user-check link-info fa-2x'></i>
<vote-complete-user-list
v-for="(item, index) in voetedUsers"
:key="index"
@ -11,7 +11,7 @@
</div>
<!-- 투표안한 사람 목록 -->
<div class="d-flex align-items-center gap-2 ms-auto flex-wrap">
<i class='bx bxs-user-x link-danger fa-3x'></i>
<i class='bx bxs-user-x link-danger fa-2x'></i>
<vote-in-complete-user-list
v-for="(item, index) in noVoetedUsers"
:key="index"

View File

@ -1,5 +1,5 @@
<template>
<li class="card p-5 mb-2">
<li class="card p-4 mb-2">
<DictWrite
v-if="writeStore.isItemActive(item.WRDDICSEQ)"
@close="writeStore.closeAll();"
@ -16,8 +16,9 @@
<div v-else>
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<span class="btn btn-primary pe-none">{{ item.category }}</span>
<strong class="mx-2 w-75">{{ item.WRDDICTTL }}</strong>
<span class="btn btn-primary pe-none m-2"
style="writing-mode: horizontal-tb;">{{ item.category }}</span>
{{ item.WRDDICTTL }}
</div>
<EditBtn
@click="toggleEdit"
@ -31,7 +32,7 @@
<div class="d-flex flex-wrap align-items-center me-4">
<div class="avatar me-2">
<img
class="rounded-circle user-avatar"
class="rounded-circle user-avatar object-fit-cover"
:src="getProfileImage(item.author.profileImage)"
alt="최초 작성자"
:style="{ borderColor: item.author.color }"
@ -50,7 +51,7 @@
>
<div class="avatar me-2">
<img
class="rounded-circle user-avatar"
class="rounded-circle user-avatar object-fit-cover"
:src="getProfileImage(item.lastEditor.profileImage)"
alt="최근 작성자"
:style="{ borderColor: item.lastEditor.color }"
@ -163,7 +164,6 @@ const toggleEdit = async () => {
.user-avatar {
border: 3px solid;
padding: 0.1px;
}
.edit-btn {
@ -178,4 +178,21 @@ const toggleEdit = async () => {
top: -0.5rem;
--bs-form-check-bg: #fff;
}
.btn.btn-primary {
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>

View File

@ -49,9 +49,9 @@
@keyup="ValidHandler('title')"
/>
<div>
<QEditor class="" @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">
<button class="btn btn-primary" @click="saveWord">
<button class="btn btn-primary" @click="saveWord" :disabled="titleValue ? !changed : false">
<i class="bx bx-check"></i>
</button>
</div>
@ -67,39 +67,6 @@ import FormSelect from '@/components/input/FormSelect.vue';
import PlusBtn from '../button/PlusBtn.vue';
import EditBtn from '../button/EditBtn.vue';
import { useWriteVisibleStore } from '@s/writeVisible';
const writeStore = useWriteVisibleStore();
const emit = defineEmits(['close','addCategory','addWord', 'toggleEdit']);
//
const wordTitle = ref('');
const addCategory = ref('');
const content = ref('');
const imageUrls = ref([]);
// Vaildation
const wordTitleAlert = ref(false);
const wordContentAlert = ref(false);
const addCategoryAlert = ref(false);
//
const selectCategory = ref('');
//
const computedTitle = computed(() =>
wordTitle.value === '' ? props.titleValue : wordTitle.value
);
//
const selectedCategory = computed(() =>
selectCategory.value === '' ? props.formValue : selectCategory.value
);
// ref
const categoryInputRef = ref(null);
const props = defineProps({
dataList: {
type: Array,
@ -126,6 +93,38 @@ const props = defineProps({
}
});
const writeStore = useWriteVisibleStore();
const emit = defineEmits(['close','addCategory','addWord', 'toggleEdit']);
//
const wordTitle = ref('');
const addCategory = ref('');
const content = ref('');
const imageUrls = ref([]);
// Vaildation
const wordTitleAlert = ref(false);
const wordContentAlert = ref(false);
const addCategoryAlert = ref(false);
const changed = ref(false);
//
const selectCategory = ref('');
//
const computedTitle = computed(() =>
wordTitle.value === '' ? props.titleValue : wordTitle.value
);
//
const selectedCategory = computed(() =>
selectCategory.value === '' ? props.formValue : selectCategory.value
);
// ref
const categoryInputRef = ref(null);
//
const showInput = ref(false);
@ -136,6 +135,7 @@ const toggleInput = () => {
const onChange = (newValue) => {
selectCategory.value = newValue.target.value;
changed.value = true;
};
const ValidHandler = (field) => {
@ -144,11 +144,22 @@ const ValidHandler = (field) => {
}
if(field == 'content'){
wordContentAlert.value = false;
}
}
const handleContentUpdate = (newContent) => {
content.value = newContent;
const oldContent = typeof props.contentValue === 'string'? JSON.parse(props.contentValue) : props.contentValue;
const newContentOps = newContent?.ops || [];
const oldContentJson = JSON.stringify(oldContent);
const newContentJson = JSON.stringify(newContentOps);
// changed = true;
changed.value = oldContentJson !== newContentJson;
ValidHandler("content"); //
};
@ -221,3 +232,11 @@ const handleCategoryFocusout = (value) => {
};
</script>
<style>
.q-editor-container * {
max-width: 100% !important;
word-break: break-all !important;
box-sizing: border-box;
white-space: normal !important;
}
</style>

View File

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

View File

@ -9,15 +9,10 @@
<!-- 날씨 정보 영역 -->
<div class="navbar-nav align-items-center">
<div class="d-flex align-items-center weather-box">
<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">
<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 }}°
</span>
</div>
@ -26,7 +21,7 @@
<div class="d-flex align-items-center ms-auto" id="navbar-collapse">
<ul class="navbar-nav flex-row align-items-center ms-auto">
<select class="form-select py-1" id="name" v-model="selectedProject" @change="updateSelectedProject">
<select class="form-select py-1 cursor-pointer" id="name" v-model="selectedProject" @change="updateSelectedProject">
<!-- 내가 참여하고 있는 프로젝트 그룹 -->
<option v-for="item in myActiveProjects" :key="item.PROJCTSEQ" :value="item.PROJCTSEQ">
{{ item.PROJCTNAM }}
@ -38,143 +33,41 @@
</option>
</select>
<!-- <button class="btn p-1" @click="switchToLightMode"><i class="bx bxs-sun link-warning"></i></button> -->
<!-- <button class="btn p-1" @click="switchToDarkMode"><i class="bx bxs-moon"></i></button> -->
<i class="bx bx-bell bx-md bx-log-out cursor-pointer p-3" @click="handleLogout"></i>
<i class="cursor-pointer p-2"></i>
<!-- Notification -->
<li class="nav-item dropdown-notifications navbar-dropdown dropdown me-3 me-xl-2 p-0">
<a
class="nav-link dropdown-toggle hide-arrow p-0"
href="javascript:void(0);"
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false"
class="nav-link dropdown-toggle hide-arrow p-0"
href="javascript:void(0);"
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-expanded="false"
>
<span class="position-relative">
<i class="bx bx-bell bx-md"></i>
<span class="badge rounded-pill bg-danger badge-dot badge-notifications border"></span>
</span>
<span class="position-relative">
<i class="bx bx-bell bx-md"></i>
<!-- 알림이 있을 경우에만 뱃지를 표시 -->
<span
v-if="notificationCount > 0"
class="badge rounded-pill bg-danger badge-dot badge-notifications border"
></span>
</span>
</a>
<ul class="dropdown-menu dropdown-menu-end p-0">
<li class="dropdown-menu-header border-bottom">
<div class="dropdown-header d-flex align-items-center py-3">
<h6 class="mb-0 me-auto">Notification</h6>
<div class="d-flex align-items-center h6 mb-0">
<span class="badge bg-label-primary me-2">8 New</span>
<a
href="javascript:void(0)"
class="dropdown-notifications-all p-2"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Mark all as read"
><i class="bx bx-envelope-open text-heading"></i
></a>
</div>
</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>
<li class="dropdown-notifications-list scrollable-container p-3">
<!-- 알림이 없으면 "알림이 없습니다." 메시지 표시 -->
<div v-if="notificationCount === 0">
알림이 없습니다.
</div>
<!-- 알림이 있을 목록 렌더링-->
<div v-else>
<ul>
<li v-for="notification in notifications" :key="notification.id">
{{ notification.text }}
</li>
</ul>
</div>
</li>
</ul>
</li>
<!--/ Notification -->
@ -185,30 +78,12 @@
v-if="user"
:src="`${baseUrl}upload/img/profile/${user.profile}`"
alt="Profile Image"
class="w-px-40 h-px-40 rounded-circle border border-3"
class="w-px-40 h-px-40 rounded-circle border border-3 object-fit-cover"
:style="`border-color: ${user.usercolor} !important;`"
@error="$event.target.src = '/img/icons/icon.png'"
/>
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="pages-account-settings-account.html">
<div class="d-flex">
<div class="flex-shrink-0 me-3">
<div class="avatar avatar-online">
<img src="/img/avatars/1.png" class="w-px-40 h-auto rounded-circle" />
</div>
</div>
<div class="flex-grow-1">
<h6 class="mb-0">John Doe</h6>
<small class="text-muted">Admin</small>
</div>
</div>
</a>
</li>
<li>
<div class="dropdown-divider my-1"></div>
</li>
<li>
<a class="dropdown-item" href="javascript:void(0)" @click="goToMyPage">
<i class="bx bx-user bx-md me-3"></i><span>My Page</span>
@ -219,28 +94,6 @@
<i class="bx bx-cog bx-md me-3"></i><span>Settings</span>
</a>
</li>
<li>
<a class="dropdown-item" href="pages-account-settings-billing.html">
<span class="d-flex align-items-center align-middle">
<i class="flex-shrink-0 bx bx-credit-card bx-md me-3"></i
><span class="flex-grow-1 align-middle">Billing Plan</span>
<span class="flex-shrink-0 badge rounded-pill bg-danger">4</span>
</span>
</a>
</li>
<li>
<div class="dropdown-divider my-1"></div>
</li>
<li>
<a class="dropdown-item" href="pages-pricing.html">
<i class="bx bx-dollar bx-md me-3"></i><span>Pricing</span>
</a>
</li>
<li>
<a class="dropdown-item" href="pages-faq.html">
<i class="bx bx-help-circle bx-md me-3"></i><span>FAQ</span>
</a>
</li>
<li>
<div class="dropdown-divider my-1"></div>
</li>
@ -251,23 +104,17 @@
</li>
</ul>
</li>
<!--/ User -->
</ul>
</div>
<!-- Search Small Screens -->
<div class="navbar-search-wrapper search-input-wrapper d-none">
<input type="text" class="form-control search-input container-xxl border-0" placeholder="Search..." aria-label="Search..." />
<i class="bx bx-x bx-md search-toggler cursor-pointer"></i>
</div>
</nav>
</template>
<script setup>
import { useAuthStore } from '@s/useAuthStore';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useProjectStore } from '@/stores/useProjectStore';
import { useRouter } from 'vue-router';
import { useRouter, useRoute } from 'vue-router';
import { useThemeStore } from '@s/darkmode';
import { useWeatherStore } from '@/stores/useWeatherStore';
import { computed, onMounted, ref, watch } from 'vue';
import axios from '@api';
@ -277,9 +124,24 @@
const userStore = useUserInfoStore();
const projectStore = useProjectStore();
const router = useRouter();
const route = useRoute();
const weatherStore = useWeatherStore();
const user = ref(null);
const selectedProject = ref(null);
const weather = 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(() => {
@ -302,9 +164,7 @@
if (!selectedProject.value) return;
//
let selected = projectStore.activeProjectList.find(
project => project.PROJCTSEQ === selectedProject.value
);
let selected = projectStore.activeProjectList.find(project => project.PROJCTSEQ === selectedProject.value);
if (selected) {
projectStore.setSelectedProject(selected);
@ -312,15 +172,40 @@
};
//
watch(() => projectStore.selectedProject, (newProject) => {
if (newProject) {
selectedProject.value = newProject.PROJCTSEQ;
}
watch(
() => projectStore.selectedProject,
newProject => {
if (newProject) {
selectedProject.value = newProject.PROJCTSEQ;
}
},
);
const customIconUrl = computed(() => {
if (weather.value.icon === '01d' || weather.value.icon === '01n') {
return '/img/icons/sunny-custom.png';
}
return `https://openweathermap.org/img/wn/${weather.value.icon}@2x.png`;
});
const customIconClass = computed(() => {
if (weather.value.icon === '01d' || weather.value.icon === '01n') {
return 'custom-sunny-icon';
}
return 'weather-icon';
});
// const { isDarkMode, switchToDarkMode, switchToLightMode } = useThemeStore();
const handleLogout = async () => {
await authStore.logout();
router.push('/login');
};
const goToMyPage = () => {
router.push('/mypage');
};
onMounted(async () => {
// if (isDarkMode) {
// switchToDarkMode();
@ -342,115 +227,59 @@
projectStore.setSelectedProject(firstProject);
}
//
if (route.name !== 'login' && route.name !== undefined) {
//
await weatherStore.getWeatherInfoWithCache();
weather.value = weatherStore.weather; //
dailyWeatherList.value = weatherStore.dailyWeatherList; //
}
});
const weather = ref({
icon: '',
description: '',
tempMin: null,
tempMax: null,
});
const customIconUrl = computed(() => {
if (weather.value.icon === "01d" || weather.value.icon === "01n") {
return "/img/icons/sunny-custom.png";
}
return `https://openweathermap.org/img/wn/${weather.value.icon}@2x.png`;
});
const customIconClass = computed(() => {
if (weather.value.icon === "01d" || weather.value.icon === "01n") {
return "custom-sunny-icon";
}
return "weather-icon";
});
const weatherKorean = computed(() => weather.value.description || '날씨 정보 없음');
onMounted(() => {
navigator.geolocation.getCurrentPosition(async (position) => {
const lat = position.coords.latitude;
const lon = position.coords.longitude;
try {
const res = await axios.get(`/weather`, {
params: { lat, lon },
withCredentials: true,
});
const raw = res.data;
const data = JSON.parse(raw.data);
if (!data || !Array.isArray(data.list) || data.list.length === 0) {
console.error('날씨 데이터 형식 오류 또는 없음:', data);
return;
}
const now = new Date();
const closest = data.list.reduce((prev, curr) => {
const prevTime = new Date(prev.dt_txt).getTime();
const currTime = new Date(curr.dt_txt).getTime();
const nowTime = now.getTime();
const prevDiff = Math.abs(prevTime - nowTime);
const currDiff = Math.abs(currTime - nowTime);
return prevDiff < currDiff ? prev : curr;
});
weather.value.icon = closest.weather[0].icon.replace(/n$/, 'd');
weather.value.description = closest.weather[0].description;
weather.value.tempMin = Math.round(closest.main.temp_min);
weather.value.tempMax = Math.round(closest.main.temp_max);
} catch (e) {
console.error('날씨 정보 가져오기 실패:', e);
}
});
});
const handleLogout = async () => {
await authStore.logout();
router.push('/login');
};
const goToMyPage = () => {
router.push('/mypage');
};
</script>
<style scoped>
.weather-icon {
width: 40%;
height: 40%;
}
.weather-desc {
font-size: 14px;
font-weight: 500;
line-height: 1.6;
}
.weather-temp {
font-size: 13px;
color: #888;
line-height: 1.2;
}
.weather-box {
display: flex;
align-items: center;
flex-shrink: 0;
max-width: 3000px;
white-space: nowrap;
overflow: hidden;
}
.custom-sunny-icon {
width: 5%;
height: auto;
}
@media (max-width: 1200px) {
.custom-sunny-icon {
width: 6%;
.weather-icon {
width: 40%;
height: 40%;
}
}
@media (max-width: 1100px) {
.custom-sunny-icon {
width: 14%;
.weather-desc {
font-size: 14px;
font-weight: 500;
line-height: 1.6;
}
.weather-temp {
font-size: 13px;
color: #888;
line-height: 1.2;
}
/* .weather-box {
display: flex;
align-items: center;
flex-shrink: 0;
max-width: 3000px;
white-space: nowrap;
overflow: hidden;
} */
.custom-sunny-icon {
width: 50px;
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) {
.custom-sunny-icon {
width: 40px;
}
}
@media (max-width: 1100px) {
.custom-sunny-icon {
width: 30px;
}
}
}
</style>

View File

@ -1,31 +1,31 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import piniaPersist from 'pinia-plugin-persist'
import App from './App.vue'
import router from '@/router'
import dayjs from '@p/dayjs'
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import piniaPersist from 'pinia-plugin-persist';
import App from './App.vue';
import router from '@/router';
import dayjs from '@p/dayjs';
import ToastModal from '@c/modal/ToastModal.vue';
import common from '@/common/common.js'
import { useKakao } from 'vue3-kakao-maps/@utils'
import common from '@/common/common.js';
import { useKakao } from 'vue3-kakao-maps/@utils';
const pinia = createPinia()
pinia.use(piniaPersist)
const pinia = createPinia();
pinia.use(piniaPersist);
const kakaoApiKey = import.meta.env.VITE_KAKAO_MAP_KEY;
useKakao(kakaoApiKey, ['services'])
useKakao(kakaoApiKey, ['services']);
const app = createApp(App)
app.use(router)
.use(pinia)
.use(common)
.use(dayjs)
.component('ToastModal',ToastModal)
.mount('#app')
const app = createApp(App);
app.use(router).use(pinia).use(common).use(dayjs).component('ToastModal', ToastModal);
if (import.meta.env.MODE === "prod") {
// 라우트 로딩 후 앱 마우트
router.isReady().then(() => {
app.mount('#app');
});
if (import.meta.env.MODE === 'prod') {
const console = window.console || {};
console.log = function no_console() { }; // console log 막기
console.warn = function no_console() { }; // console warning 막기
console.error = function () { }; // console error 막기
console.log = function no_console() {}; // console log 막기
console.warn = function no_console() {}; // console warning 막기
console.error = function () {}; // console error 막기
}

View File

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

View File

@ -0,0 +1,122 @@
/*
작성자 : 서지희
작성일 : 2025-04-04
수정일 : 2025-04-07
설명 : 위치 기반으로 날씨를 조회하고, 10 단위 캐시로 저장합니다.
*/
import { ref } from 'vue';
import { defineStore } from 'pinia';
import $api from '@api';
export const useWeatherStore = defineStore('weather', () => {
const weather = ref({
icon: '',
description: '',
tempMin: null,
tempMax: null,
});
const dailyWeatherList = ref([]);
const getWeatherInfo = async () => {
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(async position => {
const lat = position.coords.latitude;
const lon = position.coords.longitude;
try {
const res = await $api.get(`/weather`, {
params: { lat, lon },
withCredentials: true,
});
if (!res?.data?.data) return;
const resData = res.data.data;
const raw = resData.weatherInfo;
const data = JSON.parse(raw);
if (!data || !Array.isArray(data.list)) {
console.error('날씨 데이터 형식 오류');
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);
});
};
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 {
weather,
dailyWeatherList,
getWeatherInfo,
getWeatherInfoWithCache,
};
});

View File

@ -2,7 +2,7 @@
<div class="container-xxl flex-grow-1 container-p-y pb-0">
<MainEventCalendar />
<MemberManagement v-if="isAdmin" />
<div class="row mt-4">
<div class="row g-4 mt-2">
<!-- 게시판 -->
<BoardMain />
<!-- 용어집 -->
@ -21,18 +21,18 @@
import MainVote from '@c/main/MainVote.vue';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { onMounted, ref } from 'vue';
import $api from '@api';
const userStore = useUserInfoStore();
const user = ref();
const isAdmin = ref(false);
const checkAdmin = user => {
return user?.value?.role === 'ROLE_ADMIN' ? true : false;
const checkAdmin = async user => {
const { data } = await $api.post('user/authCheck', { memberId: user.loginId });
return data.data === 'ROLE_ADMIN' ? true : false;
};
onMounted(async () => {
await userStore.userInfo();
user.value = userStore.user;
isAdmin.value = await checkAdmin(user);
isAdmin.value = await checkAdmin(userStore.user);
});
</script>

View File

@ -1,26 +1,35 @@
<template>
<div class="container text-center flex-grow-1 container-p-y">
<div class="card">
<div class="card-header d-flex flex-column">
<h3>관리자 권한 부여</h3>
<div class="user-card-container">
<div v-for="user in users" :key="user.id" class="user-card">
<!-- 프로필 사진 -->
<img :src="getProfileImage(user.photo)" class="profile-img" alt="프로필 사진" @error="setDefaultImage" />
<!-- 사용자 정보 -->
<div class="user-info">
<h5>{{ user.name }}</h5>
</div>
<!-- 권한 토글 버튼 -->
<label class="switch me-0">
<input type="checkbox" :checked="user.isAdmin" @change="toggleAdmin(user)" />
<span class="slider round"></span>
</label>
</div>
</div>
<div class="container text-center flex-grow-1 container-p-y">
<div class="card">
<div class="card-header d-flex flex-column">
<h3>관리자 권한 부여</h3>
<div class="user-card-container">
<div v-for="user in users" :key="user.id" class="user-card">
<!-- 프로필 사진 -->
<img
:src="getProfileImage(user.photo)"
class="user-avatar2"
alt="프로필 사진"
@error="setDefaultImage"
/>
<!-- 사용자 정보 -->
<div class="user-info">
<h5>{{ user.name }}</h5>
</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>
</template>
<script setup>
@ -32,65 +41,80 @@ const users = ref([]);
const toastStore = useToastStore();
const baseUrl = axios.defaults.baseURL.replace(/api\/$/, "");
const defaultProfile = "/img/icons/icon.png";
const allowedUserId = 1; // ID (!!)
const allowedUserId = 1; // ID ( )
//
async function fetchUsers() {
try {
const response = await axios.get('admin/users'); // API
// API
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');
try {
const response = await axios.get('admin/users'); // API
if (!response.data || !Array.isArray(response.data.data)) {
throw new Error("올바른 데이터 형식이 아닙니다.");
}
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) {
return photo || defaultProfile;
return photo || defaultProfile;
}
//
function setDefaultImage(event) {
event.target.src = defaultProfile;
event.target.src = defaultProfile;
}
//
async function toggleAdmin(user) {
const requestData = {
id: user.id,
role: user.isAdmin ? 'MEMBER' : 'ADMIN'
};
try {
const response = await axios.put('admin/role', requestData);
//
async function handleToggle(event, user) {
// Prevent the default checkbox toggle behavior
event.preventDefault();
if (response.status === 200) {
user.isAdmin = !user.isAdmin;
toastStore.onToast(`'${user.name}'의 권한이 '${requestData.role}'(으)로 변경되었습니다.`, 's');
} else {
throw new Error('권한 변경 실패');
}
} catch (error) {
toastStore.onToast('권한 변경에 실패했습니다.', 'e');
// : ( )
const originalState = user.isAdmin;
const newState = !originalState;
const requestData = {
id: user.id,
role: originalState ? 'MEMBER' : 'ADMIN'
};
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);
</script>
<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>

View File

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

View File

@ -47,7 +47,7 @@
<tr>
<th style="width: 11%" 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: 9%" class="text-center fw-bold">조회수</th>
</tr>
@ -63,7 +63,7 @@
>
<td class="text-center">공지</td>
<td class="cursor-pointer">
<div class="d-flex align-items-center">
<div class="d-flex flex-wrap align-items-center">
<span class="me-1">📌</span>
<span>{{ truncateTitle(notice.title) }}</span>
@ -80,7 +80,7 @@
<span v-if="isNewPost(notice.rawDate)" class="box-new badge text-white ms-2 fs-tiny"> N </span>
</div>
</td>
<td class="text-center">{{ notice.author }}</td>
<td class="text-start">{{ notice.author }}</td>
<td class="text-center">{{ notice.date }}</td>
<td class="text-center">{{ notice.views }}</td>
</tr>
@ -94,9 +94,9 @@
>
<td class="text-center">{{ post.id }}</td>
<td class="cursor-pointer">
<div class="d-flex align-items-center">
<div class="d-flex flex-wrap align-items-center">
{{ 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="Array.isArray(post.hasAttachment) && post.hasAttachment.length > 0"
@ -105,7 +105,7 @@
<span v-if="isNewPost(post.rawDate)" class="box-new badge text-white ms-2 fs-tiny">N</span>
</div>
</td>
<td class="text-center">{{ post.nickname ? post.nickname : post.author }}</td>
<td class="text-start nickname-ellipsis">{{ post.nickname ? post.nickname : post.author }}</td>
<td class="text-center">{{ post.date }}</td>
<td class="text-center">{{ post.views }}</td>
</tr>
@ -178,7 +178,7 @@
};
const truncateTitle = title => {
return title.length > 28 ? title.slice(0, 28) + '...' : title;
return title.length > 19 ? title.slice(0, 19) + '...' : title;
};
//
@ -190,9 +190,9 @@
searchText: searchText.value,
showNotice: showNotices.value,
};
//localStorage.removeItem
//
//localStorage.setItem(`boardList_${seq}`, JSON.stringify(query));
localStorage.setItem(`boardList_${seq}`, JSON.stringify(query));
};
//

View File

@ -17,9 +17,18 @@
:date="formattedBoardDate"
:isLike="false"
:isAuthor="isAuthor"
:is-edit-pushed="isEditPushed"
:is-delete-pushed="isDeletePushed"
@editClick="editClick"
@deleteClick="deleteClick"
/>
>
<!-- 목록으로 버튼 -->
<template #gobackBtn>
<button class="btn btn-label-primary btn-icon me-1" @click="goList">
<i class="bx bx-left-arrow-alt"></i>
</button>
</template>
</BoardProfile>
<!-- 비밀번호 입력창 (익명일 경우) -->
<div v-if="isPassword && unknown" class="mt-3 w-px-200 ms-auto">
@ -92,7 +101,7 @@
@updateReaction="handleUpdateReaction"
/>
</div>
<div v-if="!type" >
<div v-if="!type">
<!-- 댓글 입력 영역 -->
<BoardCommentArea
:profileName="profileName"
@ -142,7 +151,7 @@
import BoardCommentList from '@c/board/BoardCommentList.vue';
import BoardRecommendBtn from '@c/button/BoardRecommendBtn.vue';
import Pagination from '@c/pagination/Pagination.vue';
import { ref, onMounted, computed, inject } from 'vue';
import { ref, onMounted, computed, inject, provide } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useToastStore } from '@s/toastStore';
@ -165,6 +174,8 @@
const attachment = ref(false);
const comments = ref([]);
const profileImg = ref('');
const isEditPushed = ref(false);
const isDeletePushed = ref(false);
const route = useRoute();
const router = useRouter();
@ -319,6 +330,9 @@
});
fetchComments(pagination.value.currentPage);
closeAllEditTextareas();
closeAllPasswordAreas();
activeCommentBtnClass();
};
//
@ -444,6 +458,7 @@
passwordAlert.value = '';
commentAlert.value = '';
await fetchComments();
activeCommentBtnClass();
} else {
alert('댓글 작성을 실패했습니다.');
}
@ -475,6 +490,9 @@
const isUnknown = unknown?.unknown ?? false;
if (isUnknown) {
closeAllEditTextareas();
closeAllPasswordAreas();
activeCommentBtnClass();
togglePassword('edit');
} else {
router.push({ name: 'BoardEdit', params: { id: currentBoardId.value } });
@ -486,6 +504,9 @@
const isUnknown = unknown?.unknown ?? false;
if (isUnknown) {
closeAllEditTextareas();
closeAllPasswordAreas();
activeCommentBtnClass();
togglePassword('delete');
} else {
deletePost();
@ -505,11 +526,36 @@
return null;
};
const btnState = ref({});
provide('isBtnPushed', btnState);
const activeCommentBtnClass = (targetComment = null, type = 3) => {
const target = targetComment?.commentId;
let editPush = false;
let deletePush = false;
if (targetComment) {
if (type == 1) {
editPush = true;
deletePush = false;
} else if (type == 2) {
editPush = false;
deletePush = true;
}
}
btnState.value = {
target: target,
isEditPushed: editPush,
isDeletePushed: deletePush,
};
};
// ( )
const editComment = comment => {
acitveButtonType(); //
password.value = '';
passwordCommentAlert.value = '';
//currentPasswordCommentId.value = null;
isPassword.value = false; //
const targetComment = findCommentById(comment.commentId, comments.value);
@ -522,27 +568,20 @@
if (isMyComment) {
if (targetComment.isEditTextarea) {
//
targetComment.isEditTextarea = false;
currentPasswordCommentId.value = comment.commentId;
activeCommentBtnClass(targetComment, 3);
} else {
//
closeAllEditTextareas();
currentPasswordCommentId.value = null;
//
targetComment.isEditTextarea = true;
closeAllEditTextareas(); //
currentPasswordCommentId.value = null; //
targetComment.isEditTextarea = true; //
activeCommentBtnClass(targetComment, 1);
}
} else if (isAnonymous) {
if (currentPasswordCommentId.value === comment.commentId) {
//
toggleCommentPassword(comment, 'edit');
return;
toggleCommentPassword(comment, 'edit'); //
} else {
//
closeAllEditTextareas();
//
closeAllEditTextareas(); //
targetComment.isEditTextarea = false;
toggleCommentPassword(comment, 'edit');
}
@ -551,6 +590,28 @@
}
};
//
const deleteComment = async comment => {
acitveButtonType(); //
closeAllEditTextareas();
const isMyComment = comment.authorId === currentUserId.value;
//
if (unknown.value && !isMyComment) {
//
if (comment.isEditTextarea) {
comment.isEditTextarea = false;
comment.isCommentPassword = true;
toggleCommentPassword(comment, 'delete');
} else {
activeCommentBtnClass(comment, 3);
toggleCommentPassword(comment, 'delete');
}
} else {
deleteReplyComment(comment);
}
};
//
const closeAllEditTextareas = () => {
comments.value.forEach(comment => {
@ -568,30 +629,20 @@
passwordCommentAlert.value = '';
};
//
const deleteComment = async comment => {
const isMyComment = comment.authorId === currentUserId.value;
if (unknown.value && !isMyComment) {
if (comment.isEditTextarea) {
comment.isEditTextarea = false;
comment.isCommentPassword = true;
toggleCommentPassword(comment, 'delete');
} else {
toggleCommentPassword(comment, 'delete');
}
} else {
deleteReplyComment(comment);
}
};
//
const toggleCommentPassword = (comment, button) => {
if (lastCommentClickedButton.value === button && currentPasswordCommentId.value === comment.commentId) {
currentPasswordCommentId.value = null; //
password.value = '';
passwordCommentAlert.value = '';
activeCommentBtnClass(comment, 3);
} else {
if (button == 'edit') {
activeCommentBtnClass(comment, 1);
} else if (button == 'delete') {
activeCommentBtnClass(comment, 2);
}
currentPasswordCommentId.value = comment.commentId; //
password.value = '';
passwordCommentAlert.value = '';
@ -608,17 +659,48 @@
isPassword.value = false;
boardPasswordAlert.value = '';
password.value = '';
acitveButtonType();
return;
}
closeAllPasswordAreas();
if (lastClickedButton.value === button) {
isPassword.value = !isPassword.value;
boardPasswordAlert.value = '';
acitveButtonType();
} else {
isPassword.value = true;
}
lastClickedButton.value = button;
if (button == 'edit') {
acitveButtonType(1);
} else if (button == 'delete') {
acitveButtonType(2);
} else {
acitveButtonType();
}
}
};
// (, )
const acitveButtonType = type => {
//
if (type == 1) {
isEditPushed.value = true;
isDeletePushed.value = false;
lastClickedButton.value = 'edit';
//
} else if (type == 2) {
isEditPushed.value = false;
isDeletePushed.value = true;
lastClickedButton.value = 'delete';
//
} else {
isEditPushed.value = false;
isDeletePushed.value = false;
lastClickedButton.value = '';
isPassword.value = false;
}
};
//
@ -738,6 +820,7 @@
if (response.data.code === 200) {
await fetchComments(pagination.value.currentPage);
closeAllPasswordAreas();
activeCommentBtnClass();
if (targetComment) {
// " ." ,
@ -766,6 +849,7 @@
if (response.status === 200) {
togglePassword('close');
fetchComments(pagination.value.currentPage);
activeCommentBtnClass();
return;
// const targetComment = findCommentById(comment.commentId, comments.value);
@ -807,6 +891,16 @@
}
};
//
const goList = () => {
//
const getFilter = localStorage.getItem(`boardList_${currentBoardId.value}`);
router.push({
name: 'BoardList',
query: getFilter ? JSON.parse(getFilter) : '',
});
};
// ( )
const handleCommentDeleted = deletedCommentId => {
//
@ -836,9 +930,14 @@
const formattedBoardDate = computed(() => formattedDate(date.value));
const scrollToTop = () => {
window.scrollTo({ top: 0 });
};
//
onMounted(() => {
fetchBoardDetails();
fetchComments();
scrollToTop();
});
</script>

View File

@ -37,7 +37,9 @@
</label>
</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>
<!-- 비밀번호 필드 (익명게시판 선택 활성화) -->
@ -50,6 +52,8 @@
v-model="nickname"
@update:alert="nicknameAlert = $event"
@input="validateNickname"
@keypress="noSpace"
:maxlength="6"
/>
<FormInput
title="비밀번호"
@ -99,11 +103,14 @@
@update:deleteImgIndexList="handleDeleteEditorImg"
/>
</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 class="mb-4 d-flex justify-content-end">
<BackButton @click="goList" />
<!-- 저장 버튼은 항상 활성화 -->
<SaveButton @click="write" :isEnabled="isFileValid" />
</div>
</div>
@ -113,7 +120,7 @@
</template>
<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 FormInput from '@c/input/FormInput.vue';
import FormFile from '@c/input/FormFile.vue';
@ -146,6 +153,10 @@
const editorUploadedImgList = ref([]);
const editorDeleteImgList = ref([]);
const noSpace = (e) => {
if (e.key === ' ') e.preventDefault();
};
const fetchCategories = async () => {
const response = await axios.get('board/categories');
categoryList.value = response.data.data;
@ -163,10 +174,12 @@
const fileCount = computed(() => attachFiles.value.length);
//
const handleUpdateEditorImg = item => {
editorUploadedImgList.value = item;
};
//
const handleDeleteEditorImg = item => {
editorDeleteImgList.value = item;
};
@ -181,10 +194,8 @@
fileError.value = `최대 ${maxFiles}개의 파일만 업로드할 수 있습니다.`;
return;
}
fileError.value = '';
attachFiles.value = [...attachFiles.value, ...validFiles].slice(0, maxFiles);
autoIncrement.value++;
};
@ -206,7 +217,8 @@
const validateNickname = () => {
if (categoryValue.value === 300102) {
nicknameAlert.value = nickname.value.trim().length === 0;
nickname.value = nickname.value.replace(/\s/g, ''); //
nicknameAlert.value = nickname.value.length === 0;
} else {
nicknameAlert.value = false;
}
@ -221,19 +233,28 @@
}
};
/**
* validateContent:
* - 내용이 없으면 contentAlert를 true로 설정
* - 텍스트, 이미지, 비디오 하나라도 존재하면 유효한 콘텐츠로 판단
*/
const validateContent = () => {
if (!content.value?.ops?.length) {
contentAlert.value = true;
return;
}
//
const hasImage = content.value.ops.some(op => op.insert && typeof op.insert === 'object' && op.insert.image);
//
const hasText = content.value.ops.some(op => typeof op.insert === 'string' && op.insert.trim().length > 0);
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);
contentAlert.value = !(hasText || hasImage || hasVideo);
};
/** 글쓰기 */
@ -257,7 +278,7 @@
try {
const boardData = {
LOCBRDTTL: title.value,
LOCBRDTTL: title.value.trim(),
LOCBRDCON: JSON.stringify(content.value), // Delta JSON
LOCBRDNIC: categoryValue.value === 300102 ? nickname.value : null,
LOCBRDPWD: categoryValue.value === 300102 ? password.value : null,
@ -287,10 +308,10 @@
formData.append('CMNFLEORG', fileNameWithoutExt);
formData.append('CMNFLEEXT', file.name.split('.').pop());
formData.append('CMNFLESIZ', file.size);
formData.append('file', file); // 📌
formData.append('file', file);
await axios.post(`board/${boardId}/attachments`, formData, { isFormData: true });
}),
})
);
}
@ -306,8 +327,8 @@
router.push('/board');
};
/** `content` 변경 감지하여 자동 유효성 검사 실행 */
/** content 변경 감지 (deep 옵션 추가) */
watch(content, () => {
validateContent();
});
}, { deep: true });
</script>

View File

@ -1,127 +1,331 @@
<template>
<div class="container-xxl flex-grow-1 container-p-y">
<div class="card shadow-sm rounded-lg p-6 max-w-2xl mx-auto">
<h2 class="text-2xl font-semibold mb-6 text-center">마이페이지</h2>
<h3 class="text-2xl font-semibold mb-3 text-center">마이 페이지</h3>
<form @submit.prevent="handleSubmit">
<div class="text-center">
<label
for="profilePic"
class="rounded-circle cursor-pointer"
id="profileLabel"
:style="profilePreviewStyle"
></label>
<input type="file" id="profilePic" class="d-none object-fit-cover" @change="profileUpload" />
<span v-if="profilerr" class="invalid-feedback d-block">{{ profilerr }}</span>
</div>
<!-- 사용자 정보 표시 -->
<div class="mb-6 text-center">
<p class="text-gray-700 font-medium">안녕하세요, <span class="font-bold text-blue-600">{{ userInfo?.name }}</span> </p>
<p class="text-sm text-gray-500">사원번호: {{ userInfo?.employeeNo }}</p>
</div>
<div class="col-xl-12">
<div class="d-flex">
<div class="w-50 me-2">
<UserFormInput
title="입사일"
name="entryDate"
type="date"
:value="form.entryDate"
@update:data="form.entryDate = $event"
/>
</div>
<!-- MBTI 선택 -->
<label class="block font-medium mb-1">MBTI *</label>
<select v-model="form.mbti" class="border border-gray-300 rounded w-full p-2 mb-4">
<option disabled value="">선택하세요</option>
<option v-for="mbti in mbtiList" :key="mbti.code" :value="mbti.code">{{ mbti.name }}</option>
</select>
<div class="d-flex flex-column w-50">
<FormSelect
title="컬러"
name="color"
:is-row="false"
:is-label="true"
:is-common="true"
:is-color="true"
:data="colorList"
:value="form.color"
@update:data="handleColorUpdate"
/>
<span v-if="colorDuplicated" class="text-danger invalid-feedback mt-1 d-block">
이미 사용 중인 컬러입니다.
</span>
</div>
</div>
<!-- 컬러 선택 -->
<label class="block font-medium mb-1">컬러 *</label>
<select v-model="form.color" class="border border-gray-300 rounded w-full p-2 mb-1" @change="checkColor">
<option disabled value="">선택하세요</option>
<option v-for="color in colorList" :key="color.code" :value="color.code">{{ color.name }}</option>
</select>
<p v-if="colorDuplicated" class="text-red-500 text-sm mb-4">이미 사용 중인 색상입니다.</p>
<div class="d-flex">
<UserFormInput title="생년월일" name="birth" type="date"
:value="form.birth" @update:data="form.birth = $event" class="me-2 w-50" />
<FormSelect title="MBTI" name="mbti" :is-row="false" :is-label="true"
:is-common="true" :is-mbti="true" :data="mbtiList"
:value="form.mbti" @update:data="form.mbti = $event" class="w-50" />
</div>
<!-- 전화번호 -->
<label class="block font-medium mb-1">전화번호 *</label>
<input v-model="form.phone" class="border border-gray-300 rounded w-full p-2 mb-1" @blur="checkPhone" />
<p v-if="phoneDuplicated" class="text-red-500 text-sm mb-4">이미 등록된 전화번호입니다.</p>
<ArrInput title="주소" name="address" v-model="form.address" :disabled="true" />
<!-- 비밀번호 변경 -->
<label class="block font-medium mb-1">현재 비밀번호</label>
<input type="password" v-model="form.currentPassword" class="border border-gray-300 rounded w-full p-2 mb-4" />
<UserFormInput title="전화번호" name="phone" :value="form.phone"
@update:data="form.phone = $event" @blur="checkPhoneDuplicateAndFormat"
:maxlength="11" @keypress="onlyNumber" />
<span v-if="phoneFormatError" class="text-danger invalid-feedback mt-1 d-block">
전화번호 형식이 올바르지 않습니다.
</span>
<span v-if="phoneDuplicated" class="text-danger invalid-feedback mt-1 d-block">
이미 사용 중인 전화번호입니다.
</span>
<label class="block font-medium mb-1"> 비밀번호</label>
<input type="password" v-model="form.newPassword" class="border border-gray-300 rounded w-full p-2 mb-1" @input="validatePassword" />
<p v-if="passwordWarning" class="text-red-500 text-sm mb-1">비밀번호는 8자 이상, 특수문자/영어/숫자를 포함해야 합니다.</p>
<!-- 기존 비밀번호 입력 -->
<UserFormInput title="비밀번호 재설정" placeholder="기존 비밀번호를 입력하세요" name="currentPw" type="password"
:value="password.current" @update:data="password.current = $event"
@blur="checkCurrentPassword" @keypress="noSpace" />
<span v-if="passwordError" class="text-danger invalid-feedback mt-1 d-block">
비밀번호가 일치하지 않습니다.
</span>
<label class="block font-medium mb-1">비밀번호 확인</label>
<input type="password" v-model="form.newPasswordConfirm" class="border border-gray-300 rounded w-full p-2 mb-1" />
<p v-if="form.newPassword !== form.newPasswordConfirm" class="text-red-500 text-sm mb-4">비밀번호가 일치하지 않습니다.</p>
<!-- 비밀번호 재설정 -->
<div v-if="showResetPw">
<UserFormInput title="새 비밀번호" name="newPw" type="password"
:value="password.new" @update:data="password.new = $event" @keypress="noSpace" />
<span v-if="password.new && password.new.length < 4"
class="text-danger invalid-feedback mt-1 d-block">
비밀번호는 최소 4자리 이상이어야 합니다.
</span>
<span v-if="password.new === password.current"
class="text-danger invalid-feedback mt-1 d-block">
기존 비밀번호와 다르게 설정해주세요.
</span>
<button class="w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 rounded" @click="submit">
변경완료
</button>
<UserFormInput title="비밀번호 확인" name="confirmPw" type="password"
:value="password.confirm" @update:data="password.confirm = $event" @keypress="noSpace" />
<span v-if="password.confirm && password.confirm !== password.new"
class="text-danger invalid-feedback mt-1 d-block">
비밀번호와 일치하지 않습니다.
</span>
<div class="d-flex justify-content-end mt-2">
<button type="button" class="btn btn-sm btn-outline-primary"
:disabled="!canResetPassword"
@click="handlePasswordReset">
비밀번호 변경
</button>
</div>
</div>
<div class="d-flex mt-5">
<button type="submit" class="btn btn-primary w-100"
:disabled="!isChanged || phoneDuplicated || phoneFormatError || colorDuplicated">
정보 수정
</button>
</div>
</div>
</form>
</div>
</div>
</template>
<script>
import $api from "@api";
<script setup>
import { ref, computed, onMounted, watch} from 'vue';
import $api from '@api';
import UserFormInput from '@c/input/UserFormInput.vue';
import FormSelect from '@c/input/FormSelect.vue';
import ArrInput from '@c/input/ArrInput.vue';
import { useToastStore } from '@s/toastStore';
export default {
data() {
return {
userInfo: null, //
form: {
mbti: '',
color: '',
phone: '',
currentPassword: '',
newPassword: '',
newPasswordConfirm: ''
},
mbtiList: [],
colorList: [],
colorDuplicated: false,
phoneDuplicated: false,
passwordWarning: false
};
},
mounted() {
this.loadUserInfo();
this.loadMBTI();
this.loadColor();
},
methods: {
loadUserInfo() {
$api.get('/api/user/info').then(res => {
this.userInfo = res.data.result;
const toastStore = useToastStore();
// (, mbti, color )
this.form.phone = this.userInfo.phone;
this.form.mbti = this.userInfo.mbti;
this.form.color = this.userInfo.color;
});
},
loadMBTI() {
$api.get('/api/user/mbti').then(res => this.mbtiList = res.data.result);
},
loadColor() {
$api.get('/api/user/color', { params: { type: 'main' } }).then(res => this.colorList = res.data.result);
},
checkColor() {
$api.get('/api/user/checkColor', { params: { memberCol: this.form.color } })
.then(res => this.colorDuplicated = res.data.result);
},
checkPhone() {
$api.get('/api/user/checkPhone', { params: { memberTel: this.form.phone } })
.then(res => this.phoneDuplicated = !res.data.result);
},
validatePassword() {
const regex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[!@#$%^&*()_+]).{8,}$/;
this.passwordWarning = !regex.test(this.form.newPassword);
},
submit() {
if (this.colorDuplicated || this.phoneDuplicated || this.passwordWarning || this.form.newPassword !== this.form.newPasswordConfirm) {
alert('입력값을 다시 확인해주세요.');
return;
}
$api.post('/api/user/checkPassword', {
currentPassword: this.form.currentPassword
}).then(res => {
if (res.data.result) {
$api.patch('/api/user/pwNew', {
newPassword: this.form.newPassword
}).then(() => alert('비밀번호가 변경되었습니다.'));
} else {
alert('현재 비밀번호가 일치하지 않습니다.');
}
});
}
}
const noSpace = (e) => {
if (e.key === ' ') e.preventDefault();
};
const form = ref({
entryDate: '', birth: '', phone: '', color: '', mbti: '',
address: { address: '', detailAddress: '', postcode: '' },
id: ''
});
const originalData = ref({});
const profile = ref('');
const uploadedFile = ref(null);
const profileChanged = ref(false);
const profilerr = ref('');
const currentBlobUrl = ref('');
const colorDuplicated = ref(false);
const phoneDuplicated = ref(false);
const mbtiList = ref([]);
const colorList = ref([]);
const password = ref({ current: '', new: '', confirm: '' });
const passwordError = ref(false);
const phoneFormatError = ref(false);
const showResetPw = ref(false);
const canResetPassword = computed(() => {
return (
password.value.new.length >= 4 &&
password.value.new !== password.value.current &&
password.value.new === password.value.confirm
);
});
watch(
() => form.value.address.detailAddress,
(newVal) => {
if (newVal !== newVal.trim()) {
form.value.address.detailAddress = newVal.trim();
}
}
);
const isChanged = computed(() => {
const f = form.value;
const o = originalData.value;
return (
f.entryDate !== o.entryDate || f.birth !== o.birth || f.phone !== o.phone ||
f.color !== o.color || f.mbti !== o.mbti || profileChanged.value ||
f.address.address !== o.address.address ||
f.address.detailAddress !== o.address.detailAddress ||
f.address.postcode !== o.address.postcode
);
});
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const defaultProfile = "/img/icons/icon.png";
const getProfileImageUrl = (profilePath) =>
profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile;
const profilePreviewStyle = computed(() => ({
width: '100px',
height: '100px',
backgroundImage: `url(${profile.value})`,
backgroundRepeat: 'no-repeat',
backgroundSize: 'cover',
backgroundPosition: 'center'
}));
const profileUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
if (file.size > 5 * 1024 * 1024 || !['image/jpeg', 'image/png'].includes(file.type)) {
profilerr.value = '5MB 이하의 JPG/PNG 파일만 업로드 가능합니다.';
return;
}
profilerr.value = '';
if (currentBlobUrl.value) URL.revokeObjectURL(currentBlobUrl.value);
uploadedFile.value = file;
const newBlobUrl = URL.createObjectURL(file);
profile.value = newBlobUrl;
currentBlobUrl.value = newBlobUrl;
profileChanged.value = true;
};
const onlyNumber = (e) => {
if (!/[0-9]/.test(e.key)) e.preventDefault();
};
const checkPhoneDuplicateAndFormat = async () => {
const currentPhone = form.value.phone.trim();
// (010 + 8)
const phoneRegex = /^010\d{8}$/;
const isFormatValid = phoneRegex.test(currentPhone);
phoneFormatError.value = !isFormatValid;
//
if (isFormatValid) {
const response = await $api.get('/user/checkPhone', {
params: { memberTel: currentPhone },
});
// true
// false
phoneDuplicated.value = currentPhone !== originalData.value.phone && !response.data.data;
} else {
// (false )
phoneDuplicated.value = false;
}
};
const handleColorUpdate = async (colorVal) => {
form.value.color = colorVal;
colorDuplicated.value = colorVal !== originalData.value.color &&
(await $api.get('/user/checkColor', { params: { memberCol: colorVal } })).data.data;
};
const checkCurrentPassword = async () => {
if (!password.value.current) return;
const res = await $api.post('/user/checkPassword', {
id: form.value.id,
password: password.value.current
});
passwordError.value = res.data.data;
showResetPw.value = !res.data.data;
};
const handlePasswordReset = async () => {
const res = await $api.patch('/user/pwNew', {
id: form.value.id,
password: password.value.new
});
if (res.data.data) {
toastStore.onToast('비밀번호가 변경되었습니다.', 's');
password.value = { current: '', new: '', confirm: '' };
showResetPw.value = false;
passwordError.value = false;
} else {
toastStore.onToast('비밀번호 변경 실패', 'e');
}
};
const formatDate = (isoDate) => isoDate?.split('T')[0] || '';
const loadInitialData = async () => {
const user = (await $api.get('/user/userInfo')).data.data;
const serverColors = (await $api.get('/user/color', { params: { type: 'YON' } })).data.data.map(c => ({
value: c.CMNCODVAL, label: c.CMNCODNAM
}));
const matchedColor = serverColors.find(c => c.label === user.usercolor);
const colorCode = matchedColor ? matchedColor.value : user.color;
colorList.value = serverColors.some(c => c.value === colorCode)
? serverColors
: [{ value: colorCode, label: user.usercolor }, ...serverColors];
const initData = {
id: user.loginId,
entryDate: formatDate(user.isCdt),
birth: formatDate(user.birth),
phone: user.phone || '',
color: colorCode,
mbti: user.mbit || '',
address: {
address: user.address || '',
detailAddress: user.addressDetail || '',
postcode: user.zipcode || ''
}
};
form.value = { ...initData };
originalData.value = { ...initData };
profile.value = getProfileImageUrl(user.profile);
profileChanged.value = false;
const mbtiRes = await $api.get('/user/mbti');
mbtiList.value = mbtiRes.data.data.map(m => ({ value: m.CMNCODVAL, label: m.CMNCODNAM }));
};
const handleSubmit = async () => {
const formData = new FormData();
Object.entries(form.value).forEach(([k, v]) => {
if (typeof v === 'object') {
formData.append('address', v.address);
formData.append('detailAddress', v.detailAddress);
formData.append('postcode', v.postcode);
} else {
formData.append(k, v);
}
});
if (uploadedFile.value) formData.append('profileFile', uploadedFile.value);
if (form.value.color !== originalData.value.color) {
if (form.value.color) await $api.patch('/user/updateColorYon', { color: form.value.color, type: 'YON' });
if (originalData.value.color) await $api.patch('/user/updateColorChange', { color: originalData.value.color, type: 'YON' });
}
await $api.patch('/user/updateInfo', formData, { isFormData: true });
originalData.value = { ...form.value };
profileChanged.value = false;
location.reload();
toastStore.onToast('정보가 수정되었습니다.', 's');
};
onMounted(() => loadInitialData());
</script>
<style scoped>
</style>

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

@ -586,30 +586,45 @@ function updateCalendarEvents() {
const todayElement = document.querySelector(`.fc-daygrid-day[data-date="${todayStr}"]`);
if (todayElement) {
//
if (selectedDates.value.has(todayStr)) {
todayElement.classList.remove("fc-day-today"); //
todayElement.classList.add("selected-event"); //
todayElement.style.backgroundColor = 'transparent'; //
todayElement.classList.add("selected-event");
// 🔹 'half-day-am'
if (selectedDates.value.get(todayStr) === "700101") {
todayElement.classList.add("half-day-am");
todayElement.classList.remove("half-day-pm");
//
const existingOverlay = todayElement.querySelector('.half-day-overlay');
if (existingOverlay) {
todayElement.removeChild(existingOverlay);
}
// 🔹 'half-day-pm'
else if (selectedDates.value.get(todayStr) === "700102") {
todayElement.classList.add("half-day-pm");
todayElement.classList.remove("half-day-am");
const overlay = document.createElement('div');
overlay.classList.add('half-day-overlay');
const type = selectedDates.value.get(todayStr);
if (type === "700101") {
overlay.classList.add('am');
} else if (type === "700102") {
overlay.classList.add('pm');
} else {
todayElement.classList.remove("half-day-am", "half-day-pm");
//
overlay.style.width = '100%';
overlay.style.borderRadius = '4px';
}
todayElement.appendChild(overlay);
} else {
todayElement.classList.add("fc-day-today"); //
todayElement.classList.remove("selected-event", "half-day-am", "half-day-pm"); //
//
todayElement.classList.remove("selected-event", "half-day-am", "half-day-pm");
todayElement.style.backgroundColor = ''; //
const existingOverlay = todayElement.querySelector('.half-day-overlay');
if (existingOverlay) {
todayElement.removeChild(existingOverlay);
}
}
}
});
}
//
const getVacationTypeClass = (type) => {
if (type === "700101") return "half-day-am";

View File

@ -72,11 +72,13 @@ const ischeked = ref(false);
const selectedVote = ref({}); //
const router = useRouter();
const route = useRoute();
const myVote = ref('2');
onMounted(() => {
const maincvoteset = route.query.voteset || '0';
const maincmyVote = route.query.myVote || '0';
voteset.value =maincvoteset;
category.value = maincvoteset;
myVote.value = maincmyVote;
getvoteList();
});
//
@ -91,16 +93,16 @@ const changeCheck = () =>{
//
const getvoteList = () => {
$api.get('vote/getVoteList',{
//
params:
{
page: currentPage.value
,voteset:voteset.value
,myVote:ischeked.value ? '1':'0'
,myVote:myVote.value == '2' ? myVote.value : ischeked.value ? '1':'0'
}
}).then(res => {
PageData.value = res.data.data;
voteListCardData.value = res.data.data.list;
myVote.value = '';
})
};
@ -136,13 +138,15 @@ const checkedNames = (numList) => {
}
//
const endVoteId = (endVoteId) => {
$api.patch('vote/updateEndData',{
endVoteId :endVoteId
}).then((res)=>{
if(res.data.status === 'OK'){
getvoteList();
}
})
if(confirm('투표를 종료하시겠습니까?')){
$api.patch('vote/updateEndData',{
endVoteId :endVoteId
}).then((res)=>{
if(res.data.status === 'OK'){
getvoteList();
}
})
}
}
//
const voteEnded = async (id) =>{
@ -150,14 +154,16 @@ const voteEnded = async (id) =>{
}
//
const voteDelete =(id) =>{
$api.patch('vote/updateDeleteData',{
deleteVoteId :id
}).then((res)=>{
if(res.data.status === 'OK'){
toastStore.onToast('투표가 삭제되었습니다.', 's');
getvoteList();
}
})
if(confirm('투표를 삭제하시겠습니까?')){
$api.patch('vote/updateDeleteData',{
deleteVoteId :id
}).then((res)=>{
if(res.data.status === 'OK'){
toastStore.onToast('투표가 삭제되었습니다.', 's');
getvoteList();
}
})
}
}
// 1
const randomList = (data,id) =>{

View File

@ -244,4 +244,14 @@ const goList = () => {
.item-input {
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>

View File

@ -7,7 +7,7 @@
<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"
:isActive="writeStore.activeItemId === 999999"/>
<!-- -->
@ -17,8 +17,12 @@
<CategoryBtn :lists="cateList" @update:data="handleSelectedCategoryChange" :showAll="true" :selectedCategory="selectedCategory" />
</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 v-if="writeStore.isItemActive(999999)" class="ms-3 card p-5 mb-2">
@ -29,8 +33,9 @@
<!-- 에러 메시지 -->
<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
class="q-editor-container"
v-for="item in wordList"
:key="item.WRDDICSEQ"
:item="item"
@ -118,7 +123,9 @@ import { useRoute } from 'vue-router';
const maincategory = route.query.category || '';
selectedAlphabet.value = mainindexKeyword;
selectedCategory.value = maincategory;
getwordList('', selectedAlphabet.value, selectedCategory.value );
if(mainindexKeyword){
getwordList('', selectedAlphabet.value, selectedCategory.value );
}
});
const refreshWordList = (category) => {
@ -140,7 +147,7 @@ import { useRoute } from 'vue-router';
//
wordList.value = res.data.data.data;
//
total.value = res.data.data.total;
total.value = res.data.data.data.length;
})
.catch(err => {
console.error('데이터 로드 오류:', err);
@ -221,6 +228,7 @@ import { useRoute } from 'vue-router';
sendWordRequest(category, wordData, newCodName);
};
const sendWordRequest = (category, wordData, data) => {
const payload = {
WRDDICCAT: category,
WRDDICTTL: wordData.title,
@ -235,13 +243,15 @@ import { useRoute } from 'vue-router';
writeButton.value.resetButton();
}
selectedCategory.value = category;
const firstChar = getFirstCharacter(wordData.title[0]); //
selectedAlphabet.value = firstChar;
getwordList(searchText.value, selectedAlphabet.value, selectedCategory.value);
getIndex();
if(res.data.data == '2'){
const newCategory = { label: data, value: category };
cateList.value = [...cateList.value,newCategory];
}
selectedAlphabet.value = '';
}
});
};
@ -283,6 +293,32 @@ import { useRoute } from 'vue-router';
};
// /
const getFirstCharacter = (char) => {
const CHOSUNG_LIST = [
'ㄱ', 'ㄲ', 'ㄴ', 'ㄷ', 'ㄸ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅃ', 'ㅅ',
'ㅆ', 'ㅇ', 'ㅈ', 'ㅉ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'
];
if (!char || char.length === 0) return '';
const code = char.charCodeAt(0);
// (~)
if (code >= 0xAC00 && code <= 0xD7A3) {
const index = Math.floor((code - 0xAC00) / (21 * 28));
return CHOSUNG_LIST[index];
}
//
if (char.match(/[a-zA-Z]/)) {
return char.toLowerCase();
}
// (, )
return char;
};
</script>
<style scoped>
@ -300,4 +336,11 @@ import { useRoute } from 'vue-router';
height: fit-content;
}
.q-editor-container {
max-width: 100%; /* 영역이 넘치지 않게 */
overflow: auto; /* 넘치는 내용은 스크롤로 처리 */
word-wrap: break-word; /* 긴 단어는 자동으로 줄바꿈 */
white-space: normal; /* 내용이 길어지면 자동으로 줄바꿈 */
}
</style>