Compare commits

..

661 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
4c5da13291 메인달력 오류 수정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-04-01 19:56:39 +09:00
61767d6b68 main 달력 css 수정 2025-04-01 18:51:03 +09:00
fabda99d78 수정 2025-04-01 16:20:17 +09:00
b348fb14ae Merge branch 'khj' 2025-04-01 16:17:17 +09:00
61a3041a5e 메인 -용어집 투표진행 추가가 2025-04-01 16:16:42 +09:00
a42b5ac191 . 2025-04-01 15:45:59 +09:00
6357a8eb44 아이콘 반응형 2025-04-01 15:45:02 +09:00
9785b96de6 메인 수정 2025-04-01 15:32:19 +09:00
2cb2c81f84 . 2025-04-01 15:29:12 +09:00
58cbb8f534 Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front 2025-04-01 15:24:55 +09:00
0bf6b99189 1 2025-04-01 15:24:54 +09:00
869423c764 맑음 이미지 변환 2025-04-01 15:21:22 +09:00
81909fa6e0 . 2025-04-01 14:50:55 +09:00
e5acfa5cf3 날씨 api 백엔드로 수정 2025-04-01 14:45:21 +09:00
f975273c47 탑바수정
Some checks failed
LocalNet_front/pipeline/head There was a failure building this commit
2025-04-01 14:18:26 +09:00
6f02921c12 Merge branch 'mypage'
Some checks failed
LocalNet_front/pipeline/head There was a failure building this commit
2025-04-01 14:12:34 +09:00
6ac05b2a99 Merge branch 'mypage' 2025-04-01 14:12:23 +09:00
37dc13a92a 탑바 날씨 2025-04-01 14:11:15 +09:00
51e3065400 오늘 날짜 클릭시 초록색 -> 하늘색 2025-04-01 14:08:58 +09:00
453f1d46f7 Merge branch '250401_main_park' 2025-04-01 13:10:41 +09:00
b6e2623fe3 사원 등록 api 작업 완료 2025-04-01 13:10:19 +09:00
c6f2743d82 . 2025-04-01 11:44:45 +09:00
5cb43eac9f 리스트...추가,공지좋아요,댓글 없애기 2025-04-01 11:22:07 +09:00
ebad7d3d5f dd 2025-04-01 11:13:20 +09:00
f2a9ad693f 메인병합 2025-04-01 10:58:14 +09:00
13cf6e56ab Merge branch 'main' into mypage 2025-04-01 10:55:25 +09:00
05769d18ff .. 2025-04-01 10:54:43 +09:00
675eb93587 메인보드 2025-04-01 10:52:24 +09:00
826aeca42a Merge branch 'khj' 2025-04-01 10:27:33 +09:00
5abf73892b 메인 컴포넌트 추가가 2025-04-01 10:27:03 +09:00
0e901ccaa0 Merge branch 'main' into mypage 2025-04-01 09:46:37 +09:00
7a0df53121 Merge branch 'main' into mypage 2025-04-01 09:46:30 +09:00
3e3849d0d1 . 2025-04-01 09:44:17 +09:00
e9f3a6c8a6 게시글 수정 원복 2025-03-31 20:24:03 +09:00
yoon
9e2b3a072d boder-color 추가 2025-03-31 19:10:50 +09:00
yoon
551569c1c6 뒤로가기 수정 2025-03-31 18:47:26 +09:00
yoon
12297153e4 map 2025-03-31 17:20:16 +09:00
yoon
19777906a4 비번 체크 2025-03-31 16:46:39 +09:00
f054467887 내가쓴 투표일때 이름밀림 수정정 2025-03-31 16:06:10 +09:00
2e596009ad 게시판 메인인 2025-03-31 16:01:05 +09:00
02ec5271b3 프로필 크기 크게 변경경 2025-03-31 15:57:29 +09:00
f0ff9ced0d 메인 2025-03-31 15:50:42 +09:00
7f6caed69e Merge branch 'main' into mypage 2025-03-31 15:46:06 +09:00
931c4b6c9a 마이페이지지 2025-03-31 14:52:55 +09:00
666c081813 카드 크기 변경
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-31 14:00:41 +09:00
174867fc35 장소 입력 maxlength 20 2025-03-31 13:48:05 +09:00
8375ffee38 . 2025-03-31 13:37:28 +09:00
330f05e92b 사원등록 if 조건 변경 2025-03-31 13:32:01 +09:00
ac7b4d558e 수정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-31 13:29:30 +09:00
1c57198839 오류 수정 2025-03-31 13:25:31 +09:00
yoon
6430d45279 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-03-31 13:14:13 +09:00
yoon
7c485dc711 위치 로직 수정, 퇴근위치 설정 2025-03-31 13:14:09 +09:00
61096b84f2 config
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-31 13:12:00 +09:00
f5038a5f8e Merge branch 'workMain'
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-31 13:04:18 +09:00
972fd6ae96 1303 2025-03-31 13:03:45 +09:00
yoon
09fd2df838 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-03-31 12:22:03 +09:00
yoon
ceada27663 오타 수정 및 아이디 길이 체크 추가 2025-03-31 12:21:57 +09:00
c1274cf9a0 메인 이벤트 달력 2025-03-31 11:22:26 +09:00
95d1547400 Merge branch 'khj' 2025-03-31 11:19:52 +09:00
c0801c20a3 종료투표색변경경 2025-03-31 11:19:21 +09:00
18f47fff5f . 2025-03-31 11:11:43 +09:00
68607e3d1f .
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-31 11:02:54 +09:00
9811ed7ca2 .
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-31 11:00:47 +09:00
c139358135 휴가 왕관 수정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-31 10:54:56 +09:00
fcb8d4535e 휴가 왕관 아이콘
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-31 10:26:12 +09:00
e270b0bb88 파일선택 > 삭제했다가 다시 파일선택시 안들어감 2025-03-28 18:53:40 +09:00
9c28054001 Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front 2025-03-28 18:50:43 +09:00
8d1748548e 파일선택 > 삭제했다가 다시 파일선택시 안들어감 2025-03-28 18:48:23 +09:00
yoon
5b82d3d315 Merge branch 'main' into commuters 2025-03-28 17:05:31 +09:00
yoon
da13750404 map 2025-03-28 17:04:45 +09:00
ecb321c9fb Merge branch 'main' into board-ji 2025-03-28 16:34:01 +09:00
5c5a2c63ef Merge branch 'main' into board-ji 2025-03-28 16:33:49 +09:00
9035d339ac 수정 에디터 2025-03-28 16:32:53 +09:00
ad3af31e37 검색 필터링 유지 2025-03-28 16:18:06 +09:00
yoon
5af68dcf3d Merge branch 'main' into commuters
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-28 15:59:04 +09:00
yoon
657312b473 map 공통 파일 뺴기 미완성 2025-03-28 15:58:33 +09:00
yoon
c90ce680fc min 삭제 2025-03-28 15:57:56 +09:00
d47ee95f56 공지 좋아요,댓글 없게 2025-03-28 15:23:34 +09:00
2c0a6f5ffc Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-28 15:22:08 +09:00
969f566187 삭제된것 디세이블 투표표 2025-03-28 15:22:06 +09:00
yoon
525a484615 스타일 삭제 2025-03-28 15:15:44 +09:00
yoon
48dd6c5b9c 비밀번호 4자리 alert 누락 추가 2025-03-28 15:11:00 +09:00
yoon
51f750ae1c Merge branch 'main' into commuters 2025-03-28 15:02:57 +09:00
yoon
89f151d7bc 커서추가 2025-03-28 15:02:19 +09:00
yoon
767c160acd kakao 확인 2025-03-28 14:04:53 +09:00
21165d1d54 Merge branch '250328_comment'
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-28 14:00:01 +09:00
7fd115ddc7 댓글 수정사항 없으면 submit 버튼 disabled 처리 2025-03-28 13:59:38 +09:00
f733569211 Merge branch 'khj'
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-28 13:44:15 +09:00
7485b6e7c8 종료날짜 vaild 추가가 2025-03-28 13:43:51 +09:00
2baebdf1e9 Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front 2025-03-28 13:21:59 +09:00
750cbf4b74 1 2025-03-28 13:21:58 +09:00
83abdded54 Merge branch 'main' into board-ji 2025-03-28 13:19:48 +09:00
008adac7a5 Merge branch 'main' into board-ji 2025-03-28 13:19:35 +09:00
1dcde5eb6b 글수정 2025-03-28 13:17:33 +09:00
9bfd16efa2 Merge branch '250328_like_dislike' 2025-03-28 13:08:36 +09:00
ad4bcb511f 댓글 사용자 좋아요 싫어요 상태 업데이트 2025-03-28 13:08:21 +09:00
fdb1f6f339 Merge branch '250328_like_dislike' 2025-03-28 12:28:23 +09:00
3f8718831f 사용자의 게시글 좋아요 싫어요 상태 업데이트 2025-03-28 12:27:26 +09:00
d4bfc164a4 공지 복구 2025-03-28 11:19:41 +09:00
e447968ecf 게시글 수정 시 상세 페이지로 이동 하도록 변경 2025-03-28 10:29:11 +09:00
3b50c84118 Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front 2025-03-28 10:08:29 +09:00
416ec12a20 250328 10am 2025-03-28 10:08:22 +09:00
6c7ccff8ea 공지사항 댓글,좋아요기능 삭제 2025-03-28 10:07:13 +09:00
a540feb851 250328_work 2025-03-28 09:22:44 +09:00
4a8f74c357 Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front 2025-03-28 01:10:20 +09:00
de04cf679b 검색/페이징/필터링 후 수정시 검색어/페이징 풀림 2025-03-28 01:08:01 +09:00
yoon
4395be2e90 달력 모달 2025-03-27 18:53:44 +09:00
yoon
9e468d6ea9 Merge branch 'main' into commuters
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-27 18:12:55 +09:00
yoon
dd268c0df6 퇴근지 추가 2025-03-27 18:12:11 +09:00
42987e8554 공지 댓글 삭제 2025-03-27 16:22:17 +09:00
yoon
8dcb69bcb6 select 디폴트옵션 이상한거 수정 2025-03-27 15:54:52 +09:00
6e14790e41 Merge branch 'khj' 2025-03-27 15:42:11 +09:00
09afafdab1 수정정 2025-03-27 15:41:33 +09:00
e39f91e4bf Merge branch 'main' into board-ji 2025-03-27 14:46:25 +09:00
3631c49e43 Merge branch 'main' into board-ji 2025-03-27 14:46:10 +09:00
948199c724 검색 2글자 이상 2025-03-27 14:43:01 +09:00
c3d22b3e4f 검색수정정 2025-03-27 14:39:15 +09:00
d03475bc06 폰드 포멧팅 수정정 2025-03-27 14:34:41 +09:00
f23a7a7e89 영역 수정정 2025-03-27 14:24:57 +09:00
43bcaf1b75 Merge branch 'khj' 2025-03-27 14:08:39 +09:00
a6904fce09 커서 줌줌 2025-03-27 14:08:13 +09:00
yoon
d40646ff76 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-27 13:56:18 +09:00
yoon
913146393f log -> 등록,수정자로 변경 2025-03-27 13:55:52 +09:00
838640e2d4 Merge branch 'khj' 2025-03-27 13:53:47 +09:00
e6242771b0 용어집수정정 2025-03-27 13:53:19 +09:00
fdf439246e 휴가 수정 2025-03-27 13:37:23 +09:00
92b7d5d4ac 첨부파일 변경 2025-03-27 12:07:59 +09:00
c4ff151581 250327 작업 2025-03-27 12:04:07 +09:00
cdb40ed942 삭제된 댓글일떄 수정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-27 11:08:40 +09:00
6d428a3bd6 휴가 버튼 크기 조정 2025-03-27 10:43:52 +09:00
b6d6a100a1 버튼 색상 조정 2025-03-27 10:36:39 +09:00
b25d6758de 휴가 -3개이상 사용 x 2025-03-27 10:30:15 +09:00
5b42847028 250327 중간 커밋 2025-03-27 09:39:03 +09:00
ef1f366ce2 Merge branch 'main' into board-ji 2025-03-27 09:36:43 +09:00
9564788ad8 Merge branch 'main' into board-ji 2025-03-27 09:36:28 +09:00
yoon
4ff3aa8fa3 위치
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-25 18:48:22 +09:00
yoon
4f0bec0df3 위치 확인
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-25 18:45:30 +09:00
yoon
921e140aa4 datepicker 공통파일 2025-03-25 18:38:45 +09:00
yoon
6a4820628b 혹시몰라 추가 2025-03-25 18:12:26 +09:00
yoon
8328020eb3 오류
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-25 18:03:48 +09:00
yoon
041a69a173 오류 확인
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-25 17:56:35 +09:00
yoon
6abb6a532b 프젝 오류
Some checks failed
LocalNet_front/pipeline/head There was a failure building this commit
2025-03-25 17:53:20 +09:00
yoon
ecaf40ced2 Merge branch 'main' into commuters
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-25 17:44:50 +09:00
yoon
0da0d604e0 프로젝트 참여인원 많은 순 정렬 2025-03-25 17:44:07 +09:00
yoon
a391d677bd 이름 -> 프로젝트명 2025-03-25 17:43:46 +09:00
yoon
cf52ab8a1b 비밀번호 4자리 alert 2025-03-25 17:43:24 +09:00
031c5f35c6 비밀번호 입력 maxlength 8로 변경 2025-03-25 16:14:11 +09:00
c8662e72bf 휴가선물 마이너스일 때 불가능 2025-03-25 16:06:14 +09:00
a79a8bde73 투표종료 가능능 2025-03-25 15:21:12 +09:00
04391cc9d9 Merge branch 'main' into board-ji 2025-03-25 15:04:54 +09:00
86a5a75e9b Merge branch 'main' into board-ji 2025-03-25 15:04:47 +09:00
74bcc71a2a 폰트 2025-03-25 15:04:26 +09:00
bcf8e21ccd Merge branch 'khj' 2025-03-25 15:03:35 +09:00
4abb705a39 카드 크기 조정정 2025-03-25 15:03:09 +09:00
69234678d1 Merge branch '250325_board'
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-25 12:55:32 +09:00
e393574ae6 댓글 작성 시 줄바꿈 하고 글 쓰면 반영이 안됌 >> 반영되서 댓글 나오게 수정 2025-03-25 12:55:15 +09:00
b7026afcbd 익명 댓글 수정 할 때 삭제 버튼 두 번 눌러야지 input나옴 >> 수정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-25 12:43:45 +09:00
30e06407e3 Merge branch '250325_board' 2025-03-25 12:28:10 +09:00
e6e2062474 수정 시 목록으로 이동에서 > 상세로 이동 2025-03-25 12:27:37 +09:00
69f90e6e6e Merge branch '250325_board' 2025-03-25 12:23:40 +09:00
fe9fba3904 비밀번호 maxlength 4 설정 2025-03-25 12:23:13 +09:00
37b77f8052 Merge branch 'main' into board-ji 2025-03-25 11:20:32 +09:00
ca1a466ce2 Merge branch 'main' into board-ji 2025-03-25 11:20:18 +09:00
5082dd2de5 게시판 프로필 커서 안뜨게 2025-03-25 11:19:44 +09:00
46d9801689 Merge branch 'khj' 2025-03-25 11:04:43 +09:00
1dceec000c 수정사항 수정정 2025-03-25 11:04:08 +09:00
128c0f36b9 250325 WORK 2025-03-25 10:52:16 +09:00
33cd40038f 커스텀 버튼 추가 2025-03-25 10:44:09 +09:00
0636f74c9b 이미 사용된 타입휴가 활성화 방지 2025-03-25 10:35:09 +09:00
44de501340 Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front 2025-03-24 15:46:05 +09:00
yoon
186e8caa01 Merge branch 'main' into project-list
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-24 15:41:26 +09:00
yoon
900a92f2d8 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-24 15:40:38 +09:00
yoon
7050709c31 date type input 달력, 데이트피커 추가 2025-03-24 15:40:23 +09:00
52d520f5e4 게시글 에디터 이미지 수정, 제거 로직 추가 2025-03-24 14:01:06 +09:00
yoon
676636b246 back btn 추가 2025-03-24 10:25:49 +09:00
a72eb1f81a 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-03-24 09:47:35 +09:00
1050d64e80 minlength 원복 2025-03-24 09:47:33 +09:00
yoon
9249610442 프로제트 관련 수정 2025-03-24 09:44:44 +09:00
yoon
aa22023ca3 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-03-21 19:56:01 +09:00
yoon
fbb42682f0 용어집 css, 로직변경 2025-03-21 19:55:56 +09:00
58d590bf1a 품수정정 2025-03-21 15:10:32 +09:00
7c5437312b Merge branch 'khj' 2025-03-21 15:05:09 +09:00
93d840cf5a 투표수정정 2025-03-21 15:04:27 +09:00
5f43cc1d19 휴가 아이콘 위치 수정 2025-03-21 14:45:07 +09:00
e6a4399761 . 2025-03-21 14:42:51 +09:00
7181ff1d1b . 2025-03-21 14:42:06 +09:00
923f2683f0 . 2025-03-21 14:11:43 +09:00
1786d03061 체크박스 2025-03-21 14:11:04 +09:00
bbcce3b308 .
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-21 12:21:29 +09:00
a37d5378aa . 2025-03-21 12:19:26 +09:00
31ccd0c911 데이트 피커 위치조정 2025-03-21 11:20:58 +09:00
be3f8a8b36 댓글작성 수정정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-21 10:56:56 +09:00
e8753588ca 익명 닉네임 수정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-21 10:46:58 +09:00
57e3489af5 Merge branch 'main' into vacation 2025-03-21 10:16:23 +09:00
8324095bf4 Merge branch 'main' into vacation 2025-03-21 10:16:11 +09:00
724816eb82 게시물 닉네임 수정,회원등록 경고문구 css 수정 2025-03-21 10:14:50 +09:00
93953aedcc 에디터 첨부파일 인덱스 업데이트 로직 추가 2025-03-20 16:12:54 +09:00
8358f60d74 main 2025-03-20 16:10:40 +09:00
ae9c8735b1 커스텀 css 수정 2025-03-20 16:06:11 +09:00
7d70aef38d 폼 눌러도 달력 나오게 2025-03-20 16:02:50 +09:00
f3b7ae315f 날짜 폼만 눌러도 나오게 2025-03-20 15:26:32 +09:00
204b7a28a5 Merge branch 'khj' 2025-03-20 14:56:28 +09:00
946ec28ec8 투표수정정 2025-03-20 14:56:00 +09:00
e6eb91d571 폰트 2025-03-20 14:51:26 +09:00
yoon
5212c4f6d8 카테고리 추가 버튼 css 수정 2025-03-20 14:26:31 +09:00
yoon
aad172e90b Merge branch 'main' into style 2025-03-20 14:06:56 +09:00
yoon
2c81f1c110 new 반짝반짝 2025-03-20 14:06:26 +09:00
658660015e Merge branch 'main' into vacation 2025-03-20 13:42:19 +09:00
c3ef423b16 Merge branch 'main' into vacation 2025-03-20 13:40:40 +09:00
a171736815 폰트 찾아놓음 2025-03-20 13:38:31 +09:00
yoon
61f9fc51fd Merge branch 'main' into style 2025-03-20 13:34:41 +09:00
yoon
32bd8998d4 css 2025-03-20 13:32:24 +09:00
d2560bff1a Merge branch 'main' into board-ji 2025-03-20 13:02:17 +09:00
8d9f297138 Merge branch 'main' into board-ji 2025-03-20 13:02:08 +09:00
b67076a3ca 게시판 익명 닉네임, 경고문구css 수정 2025-03-20 13:00:55 +09:00
yoon
13a80e479a 로그인 오류 관련 alert 수정 2025-03-20 11:00:19 +09:00
733510213a Merge branch 'khj' 2025-03-20 10:53:38 +09:00
ca8ce5e59a 재수정정 2025-03-20 10:53:13 +09:00
0abaea33fd 수정정 2025-03-20 10:52:59 +09:00
bd5b1db86b 수정정 2025-03-20 10:52:19 +09:00
c22bfe1660 Merge branch 'vacation' 2025-03-20 10:25:37 +09:00
e955ac144a Merge branch 'vacation' 2025-03-20 10:25:25 +09:00
5c4a263e7a Merge branch 'main' into vacation 2025-03-20 10:25:02 +09:00
3026550abe Merge branch 'main' into vacation 2025-03-20 10:24:52 +09:00
a50799c021 카테타입추가가 2025-03-20 10:18:39 +09:00
f8cf106a54 my work temp save 2025-03-20 10:17:03 +09:00
c75e8a51ea Merge branch 'khj' 2025-03-20 10:16:38 +09:00
d78fab2143 용어집수정정 2025-03-20 10:16:05 +09:00
f25ad7ffed 카테고리수정정 2025-03-20 10:11:13 +09:00
28067c7f02 휴가 수정 2025-03-20 09:58:25 +09:00
yoon
b374d9ce80 Merge branch 'main' into project-list 2025-03-20 09:56:04 +09:00
yoon
cc567a5f4f 내가 참여하지 않는 그룹이 없는경우 나누는 라인 안보이게 수정 2025-03-20 09:54:11 +09:00
4c01039581 Merge branch 'main' into vacation 2025-03-20 09:47:55 +09:00
184bf4bd2b Merge branch 'main' into vacation 2025-03-20 09:46:50 +09:00
50d3b0d257 휴가 로직 변경 2025-03-19 17:51:28 +09:00
yoon
90d21869b3 프로젝트 참여자 관련 수정 2025-03-19 14:05:27 +09:00
6a8d1ff042 Merge branch 'main' into workMain 2025-03-18 21:34:02 +09:00
b96a24887f 수정정 2025-03-18 20:40:13 +09:00
d466af642c 수정했는데 테스트 plz 2025-03-18 20:11:11 +09:00
yoon
86602d7ffe 비밀번호 재설정 2025-03-18 16:01:46 +09:00
yoon
d4e0728d39 스크롤바 복구 2025-03-18 15:59:31 +09:00
yoon
f1b113f2a6 수정 완료시 바로 반영 2025-03-18 15:58:54 +09:00
yoon
08cd1c5922 select 프로젝트 구분 2025-03-18 15:58:42 +09:00
6d883aacb3 Merge branch 'main' into vacation 2025-03-18 15:52:46 +09:00
yoon
ad0a5653f4 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-18 15:50:42 +09:00
yoon
8c6e54316a 비밀번호 4자리 이상 2025-03-18 15:50:29 +09:00
dfb2a3e57e 필터 날짜 및 페이지 개수 100개 추가 2025-03-18 15:48:45 +09:00
701497a0b0 d
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-18 14:27:21 +09:00
156cfb0488 1
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-18 14:24:21 +09:00
2be48fe901 workMainViewPublish 2025-03-18 12:52:12 +09:00
yoon
4c380efec7 캘린더 반응형 css 수정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-18 12:50:43 +09:00
6613fed587 게시물이 없을 때
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-18 12:41:20 +09:00
3d7257338b Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front 2025-03-18 12:34:25 +09:00
8c89de1731 게시글 비밀번호 입력란 노출 로직 변경 2025-03-18 12:34:16 +09:00
f2ad06756b 캐치문 수정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-18 12:26:52 +09:00
00acd58995 에러페이지 수정 2025-03-18 12:23:23 +09:00
yoon
adbc5d0383 투표 css 수정 2025-03-18 12:17:24 +09:00
yoon
e353309c19 아이콘 pe-none 2025-03-18 12:17:15 +09:00
aaeda4b0cc 라우터 2025-03-18 11:10:49 +09:00
69baec5045 에러페이지 css 수정정 2025-03-18 11:09:11 +09:00
035aa88a26 Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front 2025-03-18 11:05:22 +09:00
yoon
402c75320c Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-18 11:03:09 +09:00
yoon
bdf2bf41bf toast css 2025-03-18 11:03:06 +09:00
92bdf8592a Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front 2025-03-18 10:57:25 +09:00
0ff4724d88 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-18 10:57:15 +09:00
72570e11a2 로그인안하고 경로로 들어갈때 모든 메뉴 막기 2025-03-18 10:57:13 +09:00
00b2fa8c0f Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front 2025-03-18 10:56:59 +09:00
29d8095107 댓글 노출 기본 최신순, 수정시 수정됨 문구 표기 2025-03-18 10:56:48 +09:00
b393a29026 수정정 2025-03-18 10:56:12 +09:00
03bb18a1e8 Merge branch 'khj' 2025-03-18 10:50:28 +09:00
b26cf05019 내가한투표수정정 2025-03-18 10:50:00 +09:00
yoon
e79bbf207c 에디터 css 2025-03-18 10:42:33 +09:00
yoon
0e0c4ceed4 필요없는 코드 삭제 2025-03-18 10:35:15 +09:00
aee91c0ce7 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-18 10:30:53 +09:00
6e3ab7acd1 권한부여 css 2025-03-18 10:30:52 +09:00
yoon
250a909ebf Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-18 10:30:44 +09:00
yoon
900d1d21a5 Merge branch 'main' into style 2025-03-18 10:30:15 +09:00
1a93a15b44 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-18 10:28:42 +09:00
53061dc5f1 Merge branch 'khj' 2025-03-18 10:28:39 +09:00
yoon
1fcff89176 커스텀 에러 처리 2025-03-18 10:28:30 +09:00
cdb08511b7 투표 오류수정정 2025-03-18 10:28:09 +09:00
201be293f8 탑바 간격조정 2025-03-18 10:20:20 +09:00
c730ea6cd3 댓글 업데이트 로직 변경
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-17 22:47:57 +09:00
yoon
942f2c660c Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-17 16:04:22 +09:00
yoon
2cc0db21ac btn css 수정 2025-03-17 16:04:19 +09:00
8feaf568a9 Merge branch 'khj' 2025-03-17 15:59:49 +09:00
145c188d19 투표 본인 ㄴ비활성화 막음음 2025-03-17 15:59:20 +09:00
yoon
0d05666532 board css 수정 2025-03-17 15:45:28 +09:00
yoon
0a514fa3a0 css 수정 2025-03-17 15:21:11 +09:00
yoon
0c32f26701 에러 해결 2025-03-17 15:19:11 +09:00
cc8e347aa9 로딩 수정 2025-03-17 15:10:07 +09:00
ce1011b96f . 2025-03-17 15:00:56 +09:00
3ee5565df6 휴가버튼수정 2025-03-17 14:59:32 +09:00
69f928d37f Merge branch 'khj' 2025-03-17 14:25:56 +09:00
eb20522969 투표인ㄷ원체크 수정정 2025-03-17 14:25:27 +09:00
d2137eed02 휴가 2025-03-17 14:17:02 +09:00
yoon
1abd8f02d8 투표 css 2025-03-17 14:09:24 +09:00
aae725398c 등록 수정정 2025-03-17 14:00:37 +09:00
69887a798f Merge branch 'khj' 2025-03-17 13:56:47 +09:00
4962ff4c92 용어어등록수정 2025-03-17 13:55:59 +09:00
yoon
99e4462ea8 container -> container-xxl 수정 2025-03-17 13:29:15 +09:00
626c8a97cb 익명 댓글과 회원 댓글 디폴트 이미지 구분
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-17 13:26:51 +09:00
yoon
1b83422555 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-17 13:22:53 +09:00
yoon
051bdcf0a0 style 삭제 2025-03-17 13:22:50 +09:00
90dc01b98d 게시글 답변 css 공통으로 변경 2025-03-17 13:19:24 +09:00
de9c8d5638 Merge branch 'khj' 2025-03-17 13:08:14 +09:00
55d9ea0ebe 버튼 수정 2025-03-17 13:07:44 +09:00
yoon
2290a2b5af 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-03-17 12:00:44 +09:00
yoon
f7abeba675 개인참여기간 2025-03-17 12:00:26 +09:00
8c9c7f147c 댓글 수정시 게시글 수정 비밀번호 입력창 노출 안되게 수정 2025-03-17 11:24:36 +09:00
58100f848c . 2025-03-17 11:14:42 +09:00
6f937d3800 게시판 수정페이지 수정 2025-03-17 11:07:24 +09:00
yoon
4d5a347459 scroll-top-btn 위치변경 2025-03-17 10:50:53 +09:00
yoon
5d4c90e604 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-17 10:44:55 +09:00
yoon
4bd8ea10af 챗 사이드바, nav 조정 2025-03-17 10:44:52 +09:00
65a20845d7 함수 수정 2025-03-17 10:36:58 +09:00
a3b17c0215 Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front 2025-03-17 10:29:04 +09:00
60069ecaae 게시판 수정 페이지 비밀번호 2025-03-17 10:29:02 +09:00
7a56ddfaa0 Merge branch 'khj' 2025-03-17 10:25:21 +09:00
4258881d4b 용어집수정정 2025-03-17 10:24:41 +09:00
5c39cb3dad Merge branch 'board_comment5' 2025-03-17 09:21:25 +09:00
277524c0c3 수정화면 비밀번호 확인 로직 추가 2025-03-17 09:13:25 +09:00
985351c8f0 오류페이지 noLayout 설정 추가 2025-03-15 00:56:52 +09:00
74be397b5d 123 2025-03-14 16:16:37 +09:00
34bef477f9 ㄱㄱ 2025-03-14 16:14:10 +09:00
3900e2cff4 에러메시지지 2025-03-14 16:06:27 +09:00
c51da7f56d 코드수정정 2025-03-14 15:37:42 +09:00
9e4207de95 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-14 15:27:43 +09:00
d5c54d8195 투표 수정 2025-03-14 15:26:50 +09:00
yoon
32268be1c6 Mobile 2025-03-14 15:15:06 +09:00
05c92bdf55 Merge branch 'main' into vacation 2025-03-14 14:32:36 +09:00
2166bc66a9 Merge branch 'main' into vacation 2025-03-14 14:32:21 +09:00
8f57e2a1eb 권한페이지 링크설정정 2025-03-14 14:31:51 +09:00
236928f1da 권한부여 2025-03-14 13:58:15 +09:00
fe8abc7f7c 용어집 수정정 2025-03-14 13:16:47 +09:00
24b75776df Merge branch 'board_comment5' 2025-03-14 10:53:04 +09:00
f761e3e15e 비밀번호 경고문 input 아래에 2025-03-14 10:52:30 +09:00
yoon
c0ea8469db 맨위로 가는 버튼 2025-03-14 10:48:22 +09:00
89b5a330d7 휴가,게시판에디터 수정 2025-03-14 10:23:50 +09:00
yoon
bd58503912 스크롤 안보이게 2025-03-14 10:06:57 +09:00
yoon
e949e4a0a8 Merge branch 'main' into commuters 2025-03-14 10:04:00 +09:00
yoon
1eba161060 출퇴근 2025-03-14 10:03:34 +09:00
84f5cf4412 댓글 수정시 공백체크 2025-03-14 01:53:35 +09:00
yoon
2ec81f274d 날짜 min 수정 2025-03-13 21:45:18 +09:00
yoon
946e3441e3 Merge branch 'main' into login 2025-03-13 21:18:18 +09:00
yoon
2a21c12017 전화번호 2025-03-13 21:15:39 +09:00
8dd206d32d console.log 삭제 2025-03-13 19:07:02 +09:00
09a665d079 front public 이미지 경로 변경
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-13 19:03:43 +09:00
dabeac68c1 익명일 경우 익명 이미지로 2025-03-13 18:57:11 +09:00
ec2f7cae09 휴가 수정정 2025-03-13 16:16:55 +09:00
yoon
f100706a83 현재위치
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-13 12:28:44 +09:00
yoon
062742c602 Merge branch 'main' into commuters
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-13 12:15:29 +09:00
yoon
8ef12c66d6 출퇴근 2025-03-13 12:14:44 +09:00
e9f7b5d358 Merge branch 'khj' 2025-03-13 12:09:09 +09:00
d38f89c5bc 휴가수정2 2025-03-13 12:08:39 +09:00
5f6a3c0fcf 투표cssㅜㅅ정정 2025-03-13 12:08:24 +09:00
f369712a81 휴가수정 2025-03-13 12:06:18 +09:00
be2608a112 비밀번호 자동입력 제외 추가, 버튼 위치 조정 2025-03-13 11:26:19 +09:00
yoon
495c714b80 Merge branch 'main' into commuters 2025-03-13 11:15:08 +09:00
yoon
a772a2b4e6 출퇴근 2025-03-13 11:14:38 +09:00
979321d533 . 2025-03-13 11:10:26 +09:00
a73ace7ac3 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-13 11:04:31 +09:00
45c1ddf17c 게시판 검색바 위치 수정 2025-03-13 11:04:29 +09:00
ff6a032cd6 Merge branch 'boardview' 2025-03-13 11:03:06 +09:00
5cb6f4ae38 인증 필요시 로그인 페이지 이동, 공통함수 설명 추가, 댓글 이미지 경로 변경(공통함수로), alert 을 토스트로 변경 2025-03-13 11:02:41 +09:00
2c1566f49e 게시판 리스트 css 수정 2025-03-13 10:59:35 +09:00
2661929cb0 . 2025-03-13 10:33:21 +09:00
4a5ab6238c 게시판 에디터 사진넘치는거 수정 2025-03-13 10:21:20 +09:00
yoon
10aaae307e Merge branch 'main' into commuters 2025-03-12 23:02:09 +09:00
yoon
ccad3596c7 출퇴근 2025-03-12 23:00:52 +09:00
fabb1c2c3f 수정 ,삭제시 비밀번호 폼에 공백 방지지 2025-03-11 16:21:20 +09:00
52c211bf08 Merge branch 'boardview' 2025-03-11 16:14:22 +09:00
5b84ef9350 삭제시 댓글 비밀번호 입력창 비활성화 2025-03-11 16:13:53 +09:00
44e72c9532 검색 바 포커스 아웃할때 검색되는거 수정 2025-03-11 16:11:58 +09:00
ff663c0c39 휴가관리 2025-03-11 16:05:58 +09:00
69fb0669be 휴가 수정완
Some checks failed
LocalNet_front/pipeline/head There was a failure building this commit
2025-03-11 16:00:12 +09:00
a3ea106265 Merge branch 'boardmodify3' 2025-03-11 15:56:55 +09:00
1454719bae 1 2025-03-11 15:56:40 +09:00
d2bc6f4272 캘린더 높이 오토 2025-03-11 15:29:52 +09:00
3fa7eff7d9 휴가 저장 2025-03-11 15:17:16 +09:00
9c47467ba7 Merge branch 'boardmodify3' 2025-03-11 15:03:50 +09:00
757370cf91 다운로드 드랍다운 메뉴 우측에 안가리게 z-index 수정 2025-03-11 15:03:30 +09:00
c8418ba292 Merge branch 'boardmodify3' 2025-03-11 14:50:29 +09:00
15bfd59c59 대댓글 폼 여는 버튼 한번 더 클릭시 닫히는데 그때 x버튼으로 바뀌게 2025-03-11 14:50:11 +09:00
ac8daab212 휴가 로그삭제제
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-11 14:36:56 +09:00
c9271aebfc 휴가 수정 2025-03-11 14:35:45 +09:00
936be84a6f Merge branch 'khj' 2025-03-11 14:27:01 +09:00
058f0c00fa 투표수정정 2025-03-11 14:26:30 +09:00
72f3622216 Merge branch 'boardmodify3' 2025-03-11 13:59:39 +09:00
538413c963 익명일때 수정,삭제 비밀번호 폼 한번 더 클릭시 폼 닫히게 2025-03-11 13:59:21 +09:00
10c429f9f1 휴가 기능오류수정 2025-03-11 13:57:36 +09:00
6e161fc2b3 Merge branch 'boardmodify3' 2025-03-11 13:35:03 +09:00
ef1101d9ee 수정 토글 닫기도 되게 수정 2025-03-11 13:33:06 +09:00
bd3d689ddd Merge branch 'boardmodify3' 2025-03-11 12:51:40 +09:00
003e9da85e console 제거 2025-03-11 12:51:21 +09:00
5778a5dd4a Merge branch 'boardmodify3' 2025-03-11 12:38:18 +09:00
fb8c00fd6b 게시글 프로필 이미지 보이게 익명일경우도 익명이미지 노출하도록 2025-03-11 12:38:03 +09:00
e04d1c65fe 휴가css 수정 2025-03-11 11:09:26 +09:00
408c72d3c0 Merge branch 'boardmodify3' 2025-03-11 11:03:33 +09:00
c3dabc4262 비밀번호 alert 노출위치 변경 2025-03-11 11:03:17 +09:00
5d2fd80098 Merge branch 'style' 2025-03-11 10:49:54 +09:00
8135926150 Merge branch 'style' 2025-03-11 10:48:38 +09:00
e33d2b870d Merge branch 'main' into style 2025-03-11 10:48:10 +09:00
ff3bbebfd5 Merge branch 'main' into style 2025-03-11 10:47:53 +09:00
19fad56ae6 Merge branch 'boardmodify3' 2025-03-11 10:42:54 +09:00
7044526284 게시글 삭제 재수정 2025-03-11 10:42:37 +09:00
61935cfb68 Merge branch 'boardmodify3' 2025-03-11 10:38:47 +09:00
93f6d1f3b9 게시글 삭제 수정 2025-03-11 10:38:12 +09:00
c5013e0ef0 Merge branch 'main' into board-ji 2025-03-11 10:35:05 +09:00
ccc195435c Merge branch 'main' into board-ji 2025-03-11 10:34:51 +09:00
7ec6424667 댓글 삭제수정 2025-03-11 10:34:10 +09:00
23f39170da 버튼수정정 2025-03-11 10:29:42 +09:00
0aad1516b7 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-11 10:21:43 +09:00
6d1905ba03 버튼변경경 2025-03-11 10:21:11 +09:00
yoon
65e620d579 Merge branch 'main' into commuters 2025-03-11 09:24:00 +09:00
yoon
400eadf3d9 휴가관리 aside style 수정 2025-03-10 21:58:23 +09:00
159dd41e4a Merge branch 'boardmodify3'
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-10 16:25:03 +09:00
c90220785c 수정시 공백체크, 수정 시 toast 2025-03-10 16:24:25 +09:00
8c2bbe188b Merge branch 'khj' 2025-03-10 16:19:29 +09:00
f70096e975 카테고리 버튼 초기화 추가가 2025-03-10 16:18:24 +09:00
yoon
eca45a84c4 Merge branch 'main' into project-list 2025-03-10 15:45:01 +09:00
yoon
2f1464bf6b 날짜 min 2025-03-10 15:44:15 +09:00
c30ccea258 휴가 수정 2025-03-10 15:38:12 +09:00
915e2476e3 용어집 수정정 2025-03-10 15:18:07 +09:00
2aa31ab64f 수정 아이콘 수정정 2025-03-10 15:10:48 +09:00
d7cc2c056a Merge branch 'khj' 2025-03-10 15:05:07 +09:00
9e48e2bc03 수정정 2025-03-10 15:04:46 +09:00
yoon
c2bac1c3fb 전화번호 숫자만, 주소 디세이블, 컬러 변경 시 alert 2025-03-10 14:57:05 +09:00
yoon
1384ae571d 출근 2025-03-10 14:25:07 +09:00
aac2e21c08 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-10 14:24:49 +09:00
df024efdd3 댓글, 비밀번호 입력 감지 경고문구 제거 2025-03-10 13:02:09 +09:00
96b9e96d7a Merge branch 'khj' 2025-03-10 13:00:53 +09:00
16b95d546c Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-10 13:00:38 +09:00
1e50403e57 튜표 수정정 2025-03-10 13:00:15 +09:00
e30c58c343 font 수정정 2025-03-10 13:00:00 +09:00
42bb7c4d55 Merge branch 'main' into vacation 2025-03-10 12:53:16 +09:00
129cc04f53 Merge branch 'main' into vacation 2025-03-10 12:53:01 +09:00
d5a62f832c 휴가 수정 2025-03-10 12:52:23 +09:00
dc4ae608eb 1.대댓글 슬롯형태로 변경 2.대댓글익명클릭시해당대댓글에비밀번호입력이나오게수정
All checks were successful
LocalNet_front/pipeline/head This commit looks good
2025-03-10 11:22:45 +09:00
3ac834dd4a 휴가 여려년도 삭제 수정 2025-03-10 10:21:23 +09:00
9c01a84749 Merge branch 'boardmodify3' 2025-03-10 10:09:30 +09:00
a76bfbcc25 익명이아닐경우비밀번호창안나오게&댓글maxlength설정 2025-03-10 10:08:40 +09:00
f7e11a5466 로딩삭제제 2025-03-07 16:24:35 +09:00
e9c0b09d93 Merge branch 'khj' 2025-03-07 16:21:31 +09:00
f2c231288c Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-07 16:20:27 +09:00
2bb292c48d 수정 투표표 2025-03-07 16:20:06 +09:00
bdcb75fc58 휴가css 수정정 2025-03-07 14:45:24 +09:00
dae3ef8ede Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front 2025-03-07 14:22:40 +09:00
a4ee364aad Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front 2025-03-07 14:22:31 +09:00
e340319595 휴가 css 수정 2025-03-07 14:19:12 +09:00
627e29ec7a 인증 관련 토스트 제거 및 브라우저 비밀번호 저장안뜨게 2025-03-07 14:16:57 +09:00
yoon
b08e72b813 console 삭제 2025-03-07 14:08:26 +09:00
144738028d 로딩 이미지 수정 2025-03-07 14:05:34 +09:00
9698a9d441 커스텀 수정 2025-03-07 13:45:46 +09:00
dd855634a0 로딩 2025-03-07 13:37:54 +09:00
4641800676 수정정 2025-03-07 13:19:19 +09:00
39dd213949 Merge branch 'main' into loading 2025-03-07 13:13:04 +09:00
b222e606c2 Merge branch 'main' into loading 2025-03-07 13:12:52 +09:00
2cc3166756 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-07 13:11:18 +09:00
0d016ca2e1 휴가관리 로딩페이지 추가 2025-03-07 12:51:33 +09:00
2af99d9b51 Merge branch 'main' of http://192.168.0.251:3000/localhost/localhost-front 2025-03-07 12:44:24 +09:00
925f6b2fbc Merge branch 'vacation-css' 2025-03-07 12:43:08 +09:00
2d29bc56ec 로딩페이지(게시판 상세,투표리스트) 2025-03-07 12:40:43 +09:00
02b3c8dd41 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-07 12:16:04 +09:00
a2f2199587 수정 2025-03-07 12:15:41 +09:00
abfc872d04 탑바 css 수정 2025-03-07 12:14:54 +09:00
c733197336 Merge branch 'khj' 2025-03-07 12:13:07 +09:00
114b56ddc7 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-07 12:12:36 +09:00
73b831a13f Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-07 12:09:36 +09:00
7a51ee37c8 수정정 2025-03-07 11:18:58 +09:00
a1de83b5cc 용어집 수정정 2025-03-07 11:12:56 +09:00
63dacb4c3c Merge branch 'khj' 2025-03-06 15:29:59 +09:00
c3b1331128 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-06 15:29:39 +09:00
e5f821e15a 용어집 수정정 2025-03-06 15:29:19 +09:00
708309f6a0 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-03-06 09:50:56 +09:00
495fe5850a Merge branch 'khj' 2025-02-27 15:45:18 +09:00
1063d82a29 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-02-27 15:44:57 +09:00
896ca99bdc randomList emit 오류류제거거 2025-02-27 15:44:30 +09:00
f248141108 Merge branch 'main' of http://192.168.0.251:3000/localnet/localhost-front 2025-02-27 14:48:55 +09:00
6942a9e063 디폴트 이미지 추가 2025-02-27 14:48:23 +09:00
113 changed files with 14454 additions and 8983 deletions

View File

@ -1,6 +1,7 @@
VITE_DOMAIN = https://192.168.0.251:5173/
VITE_DOMAIN = https://192.168.0.251:5100/
# VITE_LOGIN_URL = http://localhost:10325/ms/
VITE_SERVER = https://192.168.0.251:10300/
VITE_API_URL = https://192.168.0.251:10300/api/
VITE_TEST_URL = https://192.168.0.251:10300/test/
VITE_SERVER_IMG_URL = https://192.168.0.251:10300/upload/img/
VITE_KAKAO_MAP_KEY=6f092e8f45ee81186bb6d8408f66a492

View File

@ -3,4 +3,5 @@ VITE_DOMAIN = http://localhost:5173/
VITE_SERVER = http://localhost:10325/
VITE_API_URL = http://localhost:10325/api/
VITE_TEST_URL = http://localhost:10325/test/
VITE_SERVER_IMG_URL = http://localhost:10325/upload/img/
VITE_KAKAO_MAP_KEY=6f092e8f45ee81186bb6d8408f66a492

8467
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -19,6 +19,7 @@
"@popperjs/core": "^2.11.8",
"@tinymce/tinymce-vue": "^5.1.1",
"@vueup/vue-quill": "^1.2.0",
"@vueuse/core": "^13.0.0",
"axios": "^1.7.9",
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,163 @@
/* 1) */
@font-face {
font-family: 'NanumSquareRound';
src: url('https://cdn.jsdelivr.net/gh/projectnoonnu/noonfonts_two@1.0/NanumSquareRound.woff') format('woff');
font-weight: normal;
font-style: normal;
}
body {
font-family: 'NanumSquareRound', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
}
/* 2) */
/* @font-face {
font-family: 'Pretendard-Regular';
src: url('https://cdn.jsdelivr.net/gh/Project-Noonnu/noonfonts_2107@1.1/Pretendard-Regular.woff') format('woff');
font-weight: 400;
font-style: normal;
}
body {
font-family: 'Pretendard-Regular', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 3) */
/* @font-face {
font-family: 'NEXON Lv1 Gothic OTF';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_20-04@2.1/NEXON Lv1 Gothic OTF.woff') format('woff');
font-weight: normal;
font-style: normal;
}
body {
font-family: 'NEXON Lv1 Gothic OTF', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 4) */
/* @font-face {
font-family: 'SUITE-Regular';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_2304-2@1.0/SUITE-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
body {
font-family: 'SUITE-Regular', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 5) */
/* @font-face {
font-family: 'GoyangIlsan';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_one@1.0/GoyangIlsan.woff') format('woff');
font-weight: normal;
font-style: normal;
}
body {
font-family: 'GoyangIlsan', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 6) */
/* @font-face {
font-family: 'GowunDodum-Regular';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_2108@1.1/GowunDodum-Regular.woff') format('woff');
font-weight: normal;
font-style: normal;
}
body {
font-family: 'GowunDodum-Regular', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 7) */
/* @font-face {
font-family: 'EASTARJET-Medium';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_231029@1.1/EASTARJET-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
}
body {
font-family: 'EASTARJET-Medium', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 8) */
/* @font-face {
font-family: 'HakgyoansimChulseokbuTTF-B';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/2408-5@1.0/HakgyoansimChulseokbuTTF-B.woff2') format('woff2');
font-weight: 400;
font-style: normal;
}
body {
font-family: 'HakgyoansimChulseokbuTTF-B', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 9) */
/* @font-face {
font-family: 'GongGothicMedium';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/noonfonts_20-10@1.0/GongGothicMedium.woff') format('woff');
font-weight: normal;
font-style: normal;
}
body {
font-family: 'GongGothicMedium', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* @font-face {
font-family: 'MangoDdobak-B';
src: url('https://fastly.jsdelivr.net/gh/projectnoonnu/2405-3@1.1/MangoDdobak-B.woff2') format('woff2');
font-weight: 700;
font-style: normal;
}
body {
font-family: 'MangoDdobak-B', sans-serif;
font-size: 16px;
line-height: 1.6;
color: #333;
} */
/* 나눔고딕 */
@import url('https://fonts.googleapis.com/css2?family=Nanum+Gothic&display=swap');
/* D2Coding 폰트 */
@font-face {
font-family: 'D2Coding';
src: url('/font/D2Coding-Ver1.3.2-20180524-all.ttc') format('ttc');
src: url('/font/D2Coding-Ver1.3.2-20180524-all.ttc') format('truetype');
font-weight: normal;
font-style: normal;
}
/* Consolas 폰트 */
@font-face {
font-family: 'Consolas';
src: url('/font/Consolas.woff') format('woff');
src: url('/font/Consolas.woff') format('font-woff');
font-weight: normal;
font-style: normal;
}
/* 툴바에서 선택 가능한 폰트 및 크기 스타일 */
/* 툴바에서 선택 가능한 폰트 */
.ql-font-nanum-gothic {
font-family: 'Nanum Gothic', sans-serif;
}
@ -33,24 +175,10 @@
}
/* 폰트 크기 스타일 */
.ql-size-12px {
font-size: 12px;
}
.ql-size-14px {
font-size: 14px;
}
.ql-size-16px {
font-size: 16px;
}
.ql-size-18px {
font-size: 18px;
}
.ql-size-24px {
font-size: 24px;
}
.ql-size-32px {
font-size: 32px;
}
.ql-size-48px {
font-size: 48px;
}
.ql-size-12px { font-size: 12px; }
.ql-size-14px { font-size: 14px; }
.ql-size-16px { font-size: 16px; }
.ql-size-18px { font-size: 18px; }
.ql-size-24px { font-size: 24px; }
.ql-size-32px { font-size: 32px; }
.ql-size-48px { font-size: 48px; }

BIN
public/img/icons/Crown.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@ -15304,12 +15304,12 @@ html:not(.layout-menu-fixed) .menu-inner-shadow {
}
@media (max-width: 1199.98px) {
.layout-navbar-fixed .layout-navbar.navbar-detached {
width: calc(100% - (1.625rem * 2)) !important;
width: calc(100% - (1.625rem * 2))
}
}
@media (max-width: 991.98px) {
.layout-navbar-fixed .layout-navbar.navbar-detached {
width: calc(100% - (1rem * 2)) !important;
width: calc(100% - (1rem * 2))
}
}
.layout-navbar-fixed.layout-menu-collapsed .layout-navbar.navbar-detached {
@ -15317,12 +15317,12 @@ html:not(.layout-menu-fixed) .menu-inner-shadow {
}
@media (max-width: 1199.98px) {
.layout-navbar.navbar-detached {
width: calc(100vw - (100vw - 100%) - (1.625rem * 2)) !important;
width: calc(100vw - (100vw - 100%) - (1.625rem * 2))
}
}
@media (max-width: 991.98px) {
.layout-navbar.navbar-detached {
width: calc(100vw - (100vw - 100%) - (1rem * 2)) !important;
width: calc(100vw - (100vw - 100%) - (1rem * 2))
}
}
.layout-menu-collapsed .layout-navbar.navbar-detached, .layout-without-menu .layout-navbar.navbar-detached {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,8 @@
<template>
<link rel="stylesheet" href="/css/font.css">
<component :is="layout">
<template #content>
<LoadingSpinner :isLoading="loadingStore.isLoading" />
<router-view></router-view>
</template>
</component>
@ -12,7 +14,10 @@ import { useRoute } from 'vue-router';
import NormalLayout from './layouts/NormalLayout.vue';
import NoLayout from './layouts/NoLayout.vue';
import ToastModal from '@c/modal/ToastModal.vue';
import { useLoadingStore } from "@s/loadingStore";
import LoadingSpinner from "@v/LoadingPage.vue";
const loadingStore = useLoadingStore();
const route = useRoute();
const layout = computed(() => {

View File

@ -1,6 +1,7 @@
import axios from 'axios';
import { useRoute } from 'vue-router';
import router from '@/router';
import { useToastStore } from '@s/toastStore';
import { useLoadingStore } from '@s/loadingStore';
const $api = axios.create({
baseURL: import.meta.env.VITE_API_URL,
@ -14,6 +15,9 @@ const $api = axios.create({
*/
$api.interceptors.request.use(
function (config) {
const loadingStore = useLoadingStore();
loadingStore.startLoading();
let contentType = 'application/json';
if (config.isFormData) contentType = 'multipart/form-data';
@ -24,6 +28,8 @@ $api.interceptors.request.use(
return config;
},
function (error) {
const loadingStore = useLoadingStore();
loadingStore.stopLoading();
// 요청 오류가 있는 작업 수행
return Promise.reject(error);
},
@ -32,36 +38,80 @@ $api.interceptors.request.use(
// 응답 인터셉터 추가하기
$api.interceptors.response.use(
function (response) {
// 2xx 범위의 응답 처리
const loadingStore = useLoadingStore();
loadingStore.stopLoading();
// 로그인 요청일 경우 (헤더에 isLogin이 true로 설정된 경우)
if (response.config.headers && response.config.headers.isLogin) {
return response;
}
// 테스트 부탁
// 로그인 실패, 커스텀 에러 응답 처리
if (response.data.code > 10000) {
const toastStore = useToastStore();
const errorCode = response.data.code;
const errorMessage = response.data.message || '알 수 없는 오류가 발생했습니다.';
// 서버에서 보낸 메시지 사용
toastStore.onToast(errorMessage, 'e');
// 특정 에러 코드에 대한 추가 처리만 수행
if (errorCode === 10001) {
router.push('/login');
}
// 오류 응답 반환
return response;
}
// 일반 성공 응답 처리
return response;
},
function (error) {
const loadingStore = useLoadingStore();
loadingStore.stopLoading();
const toastStore = useToastStore();
// 오류 응답 처리
// 로그인 요청 별도 처리 (헤더에 isLogin이 true로 설정된 경우)
if (error.config && error.config.headers && error.config.headers.isLogin) {
// 로그인 페이지 오류 토스트 메시지 표시 X
return Promise.reject(error);
}
// 에러 응답에 커스텀 메시지가 포함되어 있다면 해당 메시지 사용
// if (error.response && error.response.data && error.response.data.message) {
// toastStore.onToast(error.response.data.message, 'e');
// } else if (error.response) {
if (error.response) {
// 기본 HTTP 에러 처리
switch (error.response.status) {
case 400:
toastStore.onToast('잘못된 요청입니다.', 'e');
router.push('/error/400');
break;
case 401:
if (!error.config.headers.isLogin) {
toastStore.onToast('인증이 필요합니다.', 'e');
}
toastStore.onToast('인증이 필요합니다.', 'e');
router.push('/login');
break;
case 403:
toastStore.onToast('접근 권한이 없습니다.', 'e');
break;
case 404:
toastStore.onToast('요청한 페이지를 찾을 수 없습니다.', 'e');
router.push('/error/404');
break;
case 500:
toastStore.onToast('서버 오류가 발생했습니다.', 'e');
router.push('/error/500');
break;
default:
toastStore.onToast('알 수 없는 오류가 발생했습니다.', 'e');
}
} else if (error.request) {
// 요청이 전송되었으나 응답을 받지 못한 경우
toastStore.onToast('서버와 통신할 수 없습니다.', 'e');
} else {
// 요청 설정 중에 오류가 발생한 경우
toastStore.onToast('요청 중 오류가 발생했습니다.', 'e');
}
@ -69,4 +119,5 @@ $api.interceptors.response.use(
},
);
export default $api;

View File

@ -1,18 +1,20 @@
/*
작성자 : 공현지
작성일 : 2025-01-17
수정자 :
수정일 :
수정자 : 박성용
수정일 : 2025-03-11
설명 : 공통 스크립트
*/
import Quill from 'quill';
/*
*템플릿 사용법 : $common.변수
*setup() 사용법 :
const { appContext } = getCurrentInstance();
const $common = appContext.config.globalProperties.$common;
$common.변수
*템플릿 사용법 : $common.변수
*setup() 사용법 :
const { appContext } = getCurrentInstance();
const $common = appContext.config.globalProperties.$common;
or
import { inject } from 'vue';
const $common = inject('common');
*/
const common = {
// JSON 문자열로 Delta 타입을 변환
@ -45,11 +47,11 @@ const common = {
*
* @param {string} dateStr
* @return
* 1. Date type 경우 예시 '25-02-24 12:02'
* 1. Date type 경우 예시 '2025-02-24 12:02'
* 2. Date type 아닌 경우 입력값 리턴
*
*/
dateFormatter(dateStr) {
dateFormatter(dateStr, type = null) {
const date = new Date(dateStr);
const dateCheck = date.getTime();
@ -57,13 +59,28 @@ const common = {
return dateStr;
} else {
const { year, month, day, hours, minutes } = this.formatDateTime(date);
return `${year}-${month}-${day} ${hours}:${minutes}`;
let callback = '';
if (type == 'YMD') {
callback = `${year}-${month}-${day}`;
} else if (type == 'MD') {
callback = `${month}-${day}`;
} else if (type == 'T') {
callback = `${hours}:${minutes}`;
} else {
callback = `${year}-${month}-${day} ${hours}:${minutes}`;
}
return callback;
}
},
formatDateTime(date) {
const zeroFormat = num => (num < 10 ? `0${num}` : num);
formatDateTime(dateStr) {
const date = new Date(dateStr);
const dateCheck = date.getTime();
if (isNaN(dateCheck)) return dateStr;
const zeroFormat = num => (num < 10 ? `0${num}` : num);
return {
year: date.getFullYear(),
month: zeroFormat(date.getMonth() + 1),
@ -74,13 +91,126 @@ const common = {
};
},
// 오늘 날짜시간 조회
getToday() {
const date = new Date();
return {
year: date.getFullYear(),
month: date.getMonth() + 1,
day: date.getDate(),
hours: date.getHours(),
minutes: date.getMinutes(),
seconds: date.getSeconds(),
};
},
// 해당 날짜가 오늘인지 확인
isToday(dateStr) {
const date = new Date(dateStr);
const dateCheck = date.getTime();
if (isNaN(dateCheck)) return '날짜 타입 에러';
const today = new Date();
return date.toDateString() === today.toDateString();
},
// 해당 월, 일에 맞는 목록 필터링
filterTargetByDate(target, key, month, day) {
if (!Array.isArray(target) || target.length === 0) return [];
return [...target].filter(item => {
if (!item[key]) return false;
const date = new Date(item[key]);
const MatchingMonth = date.getMonth() + 1 === parseInt(month, 10);
const MatchingDay = date.getDate() === parseInt(day, 10);
return MatchingMonth && MatchingDay;
});
},
/**
* 빈값 확인
*
* @param {} obj
* @returns
*/
isNotEmpty(obj) {
if (obj === null || obj === undefined) return false;
if (typeof obj === 'string' && obj.trim() === '') return false;
if ((Array.isArray(obj) || obj === Object(obj)) && Object.keys(obj).length === 0) return false;
if (obj === null || obj === undefined) {
return false;
}
if (typeof obj === 'string' && obj.trim() === '') {
return false;
}
if ((Array.isArray(obj) || obj === Object(obj)) && Object.keys(obj).length === 0) {
return false;
}
return true;
},
/**
* 에디터에 내용이 있는지 확인
*
* @param { Quill } content
* @returns true: 없음, false: 있음
*/
isNotValidContent(content) {
if (!content.value?.ops?.length) return true;
// 이미지 포함 여부 확인
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);
// 텍스트 또는 이미지가 하나라도 있으면 유효한 내용
return !(hasText || hasImage);
},
/**
* 확인
*
* @param { ref } text ex) inNotValidInput(data.value);
* @returns
*/
isNotValidInput(text) {
return text.trim().length === 0;
},
/**
* 프로필 이미지 반환
*
* @param { String } profileImg
* @returns
*/
getProfileImage(profileImg, isAnonymous = false) {
const defaultProfileImg = '/img/icons/icon.png'; // 기본 프로필 이미지 경로
const anonymousImg = '/img/avatars/default-Profile.jpg'; // 익명 이미지
let profileImgUrl = isAnonymous ? anonymousImg : defaultProfileImg;
const UserProfile = `${import.meta.env.VITE_SERVER}upload/img/profile/${profileImg}`;
return !profileImg || profileImg === '' ? profileImgUrl : UserProfile;
},
setDefaultImage(event, deafultImg = '/img/icons/icon.png') {
return (event.target.src = deafultImg);
},
showImage(event) {
return (event.target.style.visibility = 'visible');
},
addHyphenToPhoneNumber(phoneNum) {
const phoneNumber = phoneNum;
const length = phoneNumber.length;
if (length >= 9) {
return phoneNumber.replace(/[^0-9]/g, '').replace(/^(\d{2,3})(\d{3,4})(\d{4})$/, `$1-$2-$3`);
} else {
return phoneNum;
}
},
};
export default {

View File

@ -8,28 +8,42 @@
import { ref, onMounted } from "vue";
import $api from '@api';
const commonApi = (options = {}) => {
const colorList = ref([]);
const mbtiList = ref([]);
const pwhintList = ref([]);
const yearCategory = ref([]);
const cateList = ref([]);
const colorList = ref([]);
const mbtiList = ref([]);
const pwhintList = ref([]);
const yearCategory = ref([]);
const cateList = ref([]);
// type 파라미터를 추가로 받도록 수정
const CommonCode = async (path, endpoint, targetList, type = null) => {
const params = type ? { type } : {};
const response = await $api.get(`${path}/${endpoint}`, {
params
});
targetList.value = response.data.data.map(item => ({
const refreshColorList = async (type = 'YNP') => {
const response = await $api.get(`user/color`, {
params: { type }
});
if (response.data && response.data.data) {
colorList.value = response.data.data.map(item => ({
label: item.CMNCODNAM,
value: item.CMNCODVAL,
}));
};
}
return colorList.value;
};
// CommonCode 함수를 외부에서도 접근할 수 있게 변경
const CommonCode = async (path, endpoint, targetList, type = null) => {
const params = type ? { type } : {};
const response = await $api.get(`${path}/${endpoint}`, {
params
});
targetList.value = response.data.data.map(item => ({
label: item.CMNCODNAM,
value: item.CMNCODVAL,
}));
};
const commonApi = (options = {}) => {
onMounted(async () => {
// 요청할 데이터가 옵션으로 전달 -> 그에 맞게 호출
// color 옵션에 type 포함
if (options.loadColor) {
await CommonCode("user", "color", colorList, options.colorType);
}
@ -39,7 +53,15 @@ const commonApi = (options = {}) => {
if (options.loadCateList) await CommonCode("worddict", "getWordCategory", cateList);
});
return { colorList, mbtiList, pwhintList, yearCategory, cateList };
return {
colorList,
mbtiList,
pwhintList,
yearCategory,
cateList,
refreshColorList
};
};
export { refreshColorList };
export default commonApi;

View File

@ -4,26 +4,31 @@
:unknown="comment.author === '익명'"
:isCommentAuthor="isCommentAuthor"
:boardId="comment.boardId"
:profileName="comment.author"
:profileName="displayName"
:date="comment.createdAt"
:comment="comment"
:profileImg="comment.profileImg"
:showDetail="false"
:isLike="!isLike"
:isCommentPassword="isCommentPassword"
:isCommentProfile="true"
:is-edit-pushed="isEditPushed"
:is-delete-pushed="isDeletePushed"
@editClick="handleEditClick"
@deleteClick="$emit('deleteClick', comment)"
@updateReaction="handleUpdateReaction"
/>
<!-- 댓글 비밀번호 입력창 (익명일 경우) -->
<div v-if="currentPasswordCommentId === comment.commentId && unknown" class="mt-3 w-25 ms-auto">
<div v-if="currentPasswordCommentId === comment.commentId && unknown && comment.author == '익명'" class="mt-3 w-px-200 ms-auto">
<div class="input-group">
<input
type="password"
class="form-control"
:value="password"
autocomplete="new-password"
maxlength="8"
placeholder="비밀번호 입력"
@input="$emit('update:password', $event.target.value.trim())"
@input="filterInput"
/>
<button class="btn btn-primary" @click="logPasswordAndEmit">확인</button>
</div>
@ -32,14 +37,15 @@
<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"></SaveBtn>
<SaveBtn class="btn btn-primary" @click="submitEdit" :isEnabled="disabled"></SaveBtn>
</div>
</template>
<template v-else>
<p class="m-0">{{ comment.content }}</p>
<div class="m-0" style="white-space: pre-wrap">{{ comment.content }}</div>
</template>
</div>
<!-- <p>현재 isDeleted : {{ isDeleted }}</p> -->
@ -47,151 +53,192 @@
<!-- <template v-if="isDeleted">
<p class="m-0 text-muted">댓글이 삭제되었습니다.</p>
</template> -->
<PlusButton v-if="isPlusButton" @click="toggleComment" class="mt-6"/>
<BoardCommentArea v-if="isComment" :unknown="unknown" @submitComment="submitComment"/>
<PlusButton v-if="isPlusButton" @click="toggleComment" class="mt-6">
<i class="icon-base bx bx-plus beforeRotate" :class="{ rotate: isComment }"></i>
</PlusButton>
<!-- 대댓글 -->
<ul v-if="comment.children && comment.children.length" class="list-unstyled">
<li
v-for="child in comment.children"
:key="child.commentId"
class="mt-8 pt-6 ps-10 border-top"
>
<BoardComment
:comment="child"
:unknown="child.author === '익명'"
:isPlusButton="false"
:isLike="true"
:isCommentProfile="true"
:isCommentAuthor="child.isCommentAuthor"
:isCommentPassword="isCommentPassword"
:currentPasswordCommentId="currentPasswordCommentId"
:passwordCommentAlert="passwordCommentAlert"
:password="password"
@editClick="handleReplyEditClick"
@deleteClick="$emit('deleteClick', child)"
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent)"
@cancelEdit="$emit('cancelEdit', child)"
@submitComment="submitComment"
@updateReaction="handleUpdateReaction"
@submitPassword="$emit('submitPassword', child, password)"
@update:password="$emit('update:password', $event)"
/>
</li>
</ul>
<BoardCommentArea v-if="isComment" :unknown="unknown" @submitComment="submitComment" :commnetId="comment.commentId" />
<slot name="reply"></slot>
</div>
</template>
<script setup>
import { defineProps, defineEmits, ref, computed, watch } from 'vue';
import BoardProfile from './BoardProfile.vue';
import BoardCommentArea from './BoardCommentArea.vue';
import PlusButton from '../button/PlusBtn.vue';
import SaveBtn from '../button/SaveBtn.vue';
import { defineProps, defineEmits, ref, computed, watch, inject } from 'vue';
import BoardProfile from './BoardProfile.vue';
import BoardCommentArea from './BoardCommentArea.vue';
import PlusButton from '../button/PlusBtn.vue';
import SaveBtn from '../button/SaveBtn.vue';
const props = defineProps({
comment: {
type: Object,
required: true,
},
unknown: {
type: Boolean,
default: false,
},
isCommentAuthor: {
type: Boolean,
default: false,
},
isPlusButton: {
type: Boolean,
default: true,
},
isLike: {
type: Boolean,
default: false,
},
isEditTextarea: {
type: Boolean,
default: false
},
isDeleted: {
type: Boolean,
default: false
},
isCommentPassword: {
type: Boolean,
default: false,
},
passwordCommentAlert: {
type: String,
default: ''
},
currentPasswordCommentId: {
type: Number
},
password:{
type: String
},
});
const props = defineProps({
comment: {
type: Object,
required: true,
},
unknown: {
type: Boolean,
default: false,
},
nickname: {
type: String,
default: '',
},
isCommentAuthor: {
type: Boolean,
default: false,
},
// emits
const emit = defineEmits(['submitComment', 'updateReaction', 'editClick', 'deleteClick', 'submitPassword', 'submitEdit', 'cancelEdit', 'update:password']);
const localEditedContent = ref(props.comment.content);
//
const isComment = ref(false);
const toggleComment = () => {
isComment.value = !isComment.value;
};
//
const submitComment = (newComment) => {
emit('submitComment', { parentId: props.comment.commentId, ...newComment, LOCBRDTYP: newComment.LOCBRDTYP });
isComment.value = false;
};
// ,
const handleUpdateReaction = (reactionData) => {
emit('updateReaction', {
boardId: props.comment.boardId,
commentId: props.comment.commentId || reactionData.commentId,
...reactionData,
isPlusButton: {
type: Boolean,
default: true,
},
isLike: {
type: Boolean,
default: false,
},
isEditTextarea: {
type: Boolean,
default: false,
},
isDeleted: {
type: Boolean,
default: false,
},
isCommentPassword: {
type: Boolean,
default: false,
},
passwordCommentAlert: {
type: String,
default: '',
},
currentPasswordCommentId: {
type: Number,
},
password: {
type: String,
},
// isEditPushed: {
// type: Boolean,
// required: false,
// },
// isDeletePushed: {
// type: Boolean,
// required: false,
// },
editCommentAlert: String,
});
};
const isEditPushed = ref(false);
const isDeletePushed = ref(false);
//
const logPasswordAndEmit = () => {
emit('submitPassword', props.comment, props.password);
};
const displayName = computed(() => {
return props.nickname ? props.nickname : props.comment.author;
});
watch(() => props.comment.isEditTextarea, (newVal) => {
if (newVal) {
localEditedContent.value = props.comment.content;
}
});
// emits
const emit = defineEmits([
'submitComment',
'updateReaction',
'editClick',
'deleteClick',
'submitPassword',
'submitEdit',
'cancelEdit',
'update:password',
'inputDetector',
]);
// watch(() => props.comment.isDeleted, () => {
// console.log("BoardComment - isDeleted :", newVal);
const filterInput = event => {
event.target.value = event.target.value.replace(/\s/g, ''); //
emit('update:password', event.target.value);
};
// if (newVal) {
// localEditedContent.value = " ."; // UI
// props.comment.isEditTextarea = false;
// }
// });
const localEditedContent = ref(props.comment.content);
const isModifyContent = ref(props.comment.content);
const disabled = ref(false);
//
const submitEdit = () => {
emit('submitEdit', props.comment, localEditedContent.value);
};
//
const isComment = ref(false);
const toggleComment = () => {
isComment.value = !isComment.value;
};
const handleEditClick = () => {
emit('editClick', props.comment);
}
//
const submitComment = newComment => {
emit('submitComment', { parentId: props.comment.commentId, ...newComment, LOCBRDTYP: newComment.LOCBRDTYP });
isComment.value = false;
};
const handleReplyEditClick = (comment) => {
emit('editClick', comment);
}
// ,
const handleUpdateReaction = reactionData => {
emit('updateReaction', {
boardId: props.comment.boardId,
commentId: props.comment.commentId || reactionData.commentId,
...reactionData,
});
};
//
const logPasswordAndEmit = () => {
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 => {
if (newVal) {
localEditedContent.value = props.comment.content;
}
},
);
// text
watch(
() => localEditedContent.value,
newVal => {
if (JSON.stringify(isModifyContent.value) == JSON.stringify(newVal)) {
disabled.value = false;
return;
}
disabled.value = true;
emit('inputDetector');
},
);
// watch(() => props.comment.isDeleted, () => {
// console.log("BoardComment - isDeleted :", newVal);
// if (newVal) {
// localEditedContent.value = " ."; // UI
// props.comment.isEditTextarea = false;
// }
// });
//
const submitEdit = () => {
emit('submitEdit', props.comment, localEditedContent.value);
};
const handleEditClick = () => {
emit('editClick', props.comment);
};
</script>

View File

@ -3,46 +3,77 @@
<div class="card-body">
<!-- 댓글 입력 섹션 -->
<div class="d-flex justify-content-start align-items-top">
<!-- 프로필섹션 -->
<!-- <div class="avatar-wrapper">
<div v-if="!unknown" class="avatar me-4">
<img src="/img/avatars/11.png" alt="Avatar" class="rounded-circle">
</div>
</div> -->
<!-- 텍스트박스 -->
<div class="w-100">
<textarea class="form-control" placeholder="댓글 달기" rows="3" v-model="comment"></textarea>
<span v-if="commentAlert" class="invalid-feedback d-block text-start ms-2">{{ commentAlert }}</span>
<span v-else class="invalid-feedback d-block text-start ms-2">{{ textAlert }}</span>
<textarea
class="form-control mb-1"
placeholder="댓글 달기"
rows="3"
:maxlength="maxLength"
v-model="comment"
@input="clearAlert('comment')"
></textarea>
<span v-if="commentAlert" class="invalid-feedback d-inline text-start ms-2 mb-2">{{ commentAlert }}</span>
<span v-else class="invalid-feedback d-inline">{{ textAlert }}</span>
</div>
</div>
<!-- 옵션 버튼 섹션 -->
<div class="d-flex justify-content-between flex-wrap mt-4">
<div class="d-flex flex-wrap align-items-center">
<!-- 익명 체크박스 (익명게시판일 경우에만)-->
<div v-if="unknown" class="form-check form-check-inline mb-0 me-4">
<input class="form-check-input" type="checkbox" id="inlineCheckbox1" v-model="isCheck" />
<label class="form-check-label" for="inlineCheckbox1">익명</label>
<div class="d-flex justify-content-between align-items-center mt-1 pb-4">
<!-- 왼쪽: 익명 체크박스 -->
<div v-if="unknown" class="form-check form-check-inline mb-0 me-2">
<input
class="form-check-input"
type="checkbox"
:id="`checkboxAnnonymous${commnetId}`"
v-model="isCheck"
@change="pwd2AlertHandler"
/>
<label class="form-check-label text-nowrap" :for="`checkboxAnnonymous${commnetId}`">익명</label>
</div>
<!-- 중앙: 닉네임 & 비밀번호 입력 필드 (가로 정렬) -->
<div v-if="isCheck" class="d-flex flex-grow-1 gap-2">
<!-- 닉네임 입력 영역 -->
<div class="position-relative">
<input
type="text"
class="form-control mb-1"
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">
{{ nicknameAlert }}
</div>
</div>
<!-- 비밀번호 입력 필드 (익명이 선택된 경우에만 표시) -->
<div v-if="isCheck" class="d-flex align-items-center flex-grow-1">
<label class="form-label mb-0 me-3" for="basic-default-password">비밀번호</label>
<!-- 비밀번호 입력 영역 -->
<div class="position-relative">
<input
type="password"
id="basic-default-password"
class="form-control flex-grow-1"
class="form-control mb-1"
autocomplete="new-password"
v-model="password"
placeholder="비밀번호 입력"
placeholder="비밀번호"
maxlength="8"
@input="
password = password.replace(/\s/g, '');
clearAlert('password');
"
/>
<span v-if="passwordAlert" class="invalid-feedback d-block text-start ms-2">{{ passwordAlert }}</span>
<span v-else class="invalid-feedback d-block text-start ms-2">{{ passwordAlert2 }}</span>
<!-- 비밀번호 경고 메시지 -->
<div v-if="passwordAlert2" class="position-absolute text-danger small top-100 start-0">
{{ passwordAlert2 }}
</div>
</div>
</div>
<!-- 답변 쓰기 버튼 -->
<div class="ms-auto mt-3 mt-md-0">
<!-- 오른쪽: 답변 쓰기 버튼 -->
<div class="ms-auto">
<SaveBtn class="btn btn-primary" @click="handleCommentSubmit"></SaveBtn>
</div>
</div>
@ -51,78 +82,124 @@
</template>
<script setup>
import { ref, defineEmits, defineProps, watch, inject } from 'vue';
import SaveBtn from '../button/SaveBtn.vue';
import { ref, defineEmits, defineProps, watch, inject } from 'vue';
import SaveBtn from '../button/SaveBtn.vue';
const props = defineProps({
unknown: {
type: Boolean,
default: true,
},
parentId: {
type: Number,
default: 0,
},
passwordAlert: {
type: String,
default: '',
},
commentAlert: {
type: String,
default: '',
},
});
const $common = inject('common');
const comment = ref('');
const password = ref('');
const isCheck = ref(false);
const textAlert = ref('');
const passwordAlert2 = ref('');
const emit = defineEmits(['submitComment']);
const handleCommentSubmit = () => {
if (!$common.isNotEmpty(comment.value)) {
textAlert.value = '댓글을 입력하세요';
return false;
} else {
textAlert.value = '';
}
if (isCheck.value && !$common.isNotEmpty(password.value)) {
passwordAlert2.value = '비밀번호를 입력하세요';
return false;
} else {
passwordAlert2.value = '';
}
//
emit('submitComment', {
comment: comment.value,
password: isCheck.value ? password.value : '',
isCheck: isCheck.value,
LOCBRDTYP: isCheck.value ? '300102' : null, // '300102'
const props = defineProps({
unknown: {
type: Boolean,
default: true,
},
parentId: {
type: Number,
default: 0,
},
passwordAlert: {
type: String,
default: '',
},
commentAlert: {
type: String,
default: '',
},
maxLength: {
type: Number,
default: 500,
},
commnetId: {
type: Number,
},
});
//
resetCommentForm();
};
const noSpace = (e) => {
if (e.key === ' ') e.preventDefault();
};
//
const resetCommentForm = () => {
comment.value = '';
password.value = '';
isCheck.value = false;
};
const $common = inject('common');
const comment = ref('');
const password = ref('');
const isCheck = ref(false);
const textAlert = ref('');
const nicknameAlert = ref('');
const passwordAlert2 = ref('');
const nickname = ref('');
watch(
() => props.passwordAlert,
() => {
if (!props.passwordAlert) {
resetCommentForm();
const emit = defineEmits(['submitComment']);
//
const clearAlert = field => {
if (field === 'comment') textAlert.value = '';
if (field === 'nickname') nicknameAlert.value = '';
if (field === 'password') passwordAlert2.value = '';
};
const handleCommentSubmit = () => {
let isValid = true;
//
if (!$common.isNotEmpty(comment.value)) {
textAlert.value = '댓글을 입력해주세요.';
isValid = false;
} else {
textAlert.value = '';
}
}
);
</script>
// &
if (isCheck.value) {
if (!$common.isNotEmpty(nickname.value)) {
nicknameAlert.value = '닉네임을 입력해주세요.';
isValid = false;
} else {
nicknameAlert.value = '';
}
if (!$common.isNotEmpty(password.value)) {
passwordAlert2.value = '비밀번호를 입력해주세요.';
password.value = '';
isValid = false;
} else {
passwordAlert2.value = '';
}
}
//
if (!isValid) return;
//
emit('submitComment', {
comment: comment.value,
nickname: isCheck.value ? nickname.value : '',
password: isCheck.value ? password.value : '',
isCheck: isCheck.value,
LOCBRDTYP: isCheck.value ? '300102' : null, // '300102'
});
//
resetCommentForm();
};
// &
const pwd2AlertHandler = () => {
if (!isCheck.value) {
passwordAlert2.value = '';
nicknameAlert.value = '';
}
};
//
const resetCommentForm = () => {
comment.value = '';
password.value = '';
nickname.value = '';
isCheck.value = false;
};
watch(
() => props.passwordAlert,
() => {
if (!props.passwordAlert) {
resetCommentForm();
}
},
);
</script>

View File

@ -1,124 +1,176 @@
<template>
<ul class="list-unstyled mt-10">
<li
v-for="comment in comments"
:key="comment.commentId"
class="mt-6 border-bottom pb-6"
>
<li v-for="comment in comments" :key="comment.commentId" class="mt-6 border-bottom pb-6">
<BoardComment
:unknown="unknown"
:comment="comment"
:isCommentAuthor="comment.isCommentAuthor"
:isEditTextarea="comment.isEditTextarea"
:isDeleted="isDeleted"
:nickname="comment.nickname"
:isCommentPassword="isCommentPassword"
:passwordCommentAlert="passwordCommentAlert || ''"
:currentPasswordCommentId="currentPasswordCommentId"
:password="password"
:editCommentAlert="editCommentAlert[comment.commentId]"
:is-edit-pushed="comment.isEditPushed"
:is-delete-pushed="comment.isDeletePushed"
@editClick="handleEditClick"
@deleteClick="handleDeleteClick"
@submitPassword="submitPassword"
@submitComment="submitComment"
@submitEdit="handleSubmitEdit"
@submitEdit="(comment, editedContent) => $emit('submitEdit', comment, editedContent)"
@cancelEdit="handleCancelEdit"
@updateReaction="(reactionData) => handleUpdateReaction(reactionData, comment.commentId, comment.boardId)"
@updateReaction="reactionData => handleUpdateReaction(reactionData, comment.commentId, comment.boardId)"
@update:password="updatePassword"
/>
@inputDetector="$emit('inputDetector')"
>
<!-- 대댓글 -->
<template #reply>
<ul v-if="comment.children && comment.children.length" class="list-unstyled">
<li v-for="(child, index) in comment.children" :key="child.commentId" class="mt-8 pt-6 ps-10 border-top">
<BoardComment
:comment="child"
:unknown="child.author === '익명'"
:isPlusButton="false"
:isLike="true"
:isCommentProfile="true"
:nickname="child.nickname"
:isCommentAuthor="child.isCommentAuthor"
:isCommentPassword="isCommentPassword"
:currentPasswordCommentId="currentPasswordCommentId"
: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)"
@cancelEdit="$emit('cancelEdit', child)"
@submitComment="submitComment"
@updateReaction="handleUpdateReaction"
@submitPassword="$emit('submitPassword', child, password)"
@update:password="$emit('update:password', $event)"
@inputDetector="$emit('inputDetector')"
/>
</li>
</ul>
</template>
</BoardComment>
</li>
</ul>
</template>
<script setup>
import { defineProps, defineEmits } from 'vue';
import BoardComment from './BoardComment.vue'
import { defineProps, defineEmits, watch } from 'vue';
import BoardComment from './BoardComment.vue';
const props = defineProps({
comments: {
type: Array,
required: true,
default: () => []
},
unknown: {
type: Boolean,
default: true,
},
isCommentAuthor: {
type: Boolean,
default: false,
},
isCommentPassword: {
type: Boolean,
default: false,
},
isEditTextarea: {
type: Boolean,
default: false,
},
isDeleted: {
type: Boolean,
default: false,
},
passwordCommentAlert: {
type: String,
default: ''
},
currentPasswordCommentId: {
type: Number
},
password:{
type: String
},
});
const props = defineProps({
comments: {
type: Array,
required: true,
default: () => [],
},
unknown: {
type: Boolean,
default: true,
},
isCommentAuthor: {
type: Boolean,
default: false,
},
isCommentPassword: {
type: Boolean,
default: false,
},
isEditTextarea: {
type: Boolean,
default: false,
},
isDeleted: {
type: Boolean,
default: false,
},
passwordCommentAlert: {
type: String,
default: '',
},
currentPasswordCommentId: {
type: Number,
},
password: {
type: String,
},
index: {
type: Number,
},
editCommentAlert: Object,
});
const emit = defineEmits(['submitComment', 'updateReaction', 'editClick', 'deleteClick', 'submitPassword', 'clearPassword','submitEdit', 'update:password']);
const emit = defineEmits([
'submitComment',
'updateReaction',
'editClick',
'deleteClick',
'submitPassword',
'clearPassword',
'submitEdit',
'update:password',
'inputDetector',
]);
const submitComment = (replyData) => {
emit('submitComment', replyData);
};
const handleUpdateReaction = (reactionData, commentId, boardId) => {
const updatedReactionData = {
...reactionData,
commentId: commentId || reactionData.commentId,
boardId: boardId || reactionData.boardId,
const submitComment = replyData => {
emit('submitComment', replyData);
};
emit('updateReaction', updatedReactionData);
}
const handleUpdateReaction = (reactionData, commentId, boardId) => {
const updatedReactionData = {
...reactionData,
commentId: commentId || reactionData.commentId,
boardId: boardId || reactionData.boardId,
};
const submitPassword = (comment, password) => {
emit('submitPassword', comment, password);
};
emit('updateReaction', updatedReactionData);
};
const handleEditClick = (comment) => {
if (comment.parentId) {
emit('editClick', comment); //
} else {
emit('editClick', comment); //
}
};
const submitPassword = (comment, password) => {
emit('submitPassword', comment, password);
};
const handleSubmitEdit = (comment, editedContent) => {
emit("submitEdit", comment, editedContent);
};
const handleEditClick = comment => {
if (comment.parentId) {
emit('editClick', comment); //
} else {
emit('editClick', comment); //
}
};
const handleDeleteClick = (comment) => {
if (comment.parentId) {
emit('deleteClick', comment); //
} else {
emit('deleteClick', comment); //
}
};
const handleSubmitEdit = (comment, editedContent) => {
emit('submitEdit', comment, editedContent);
};
const handleCancelEdit = (comment) => {
if (comment.parentId) {
emit('cancelEdit', comment); //
} else {
emit('cancelEdit', comment); //
}
};
const handleDeleteClick = comment => {
if (comment.parentId) {
emit('deleteClick', comment); //
} else {
emit('deleteClick', comment); //
}
};
const updatePassword = (newPassword) => {
emit('update:password', newPassword);
};
const handleCancelEdit = comment => {
if (comment.parentId) {
emit('cancelEdit', comment); //
} else {
emit('cancelEdit', comment); //
}
};
const updatePassword = newPassword => {
emit('update:password', newPassword);
};
const handleReplyEditClick = comment => {
emit('editClick', comment);
};
</script>

View File

@ -1,17 +1,23 @@
<template>
<div class="d-flex align-items-center flex-wrap">
<div class="d-flex align-items-center">
<div v-if="!unknown" class="avatar me-2">
<img :src="getProfileImage(profilePath)" alt="Avatar" class="rounded-circle" />
<div class="avatar me-2 cursor-none">
<img
:src="getProfileImage(profileImg)"
alt="user"
class="rounded-circle profile-img"
@error="setDefaultImage($event)"
@load="showImage($event)"
/>
</div>
<div class="me-2">
<h6 class="mb-0">{{ profileName }}</h6>
<h6 class="mb-0">{{ profileName ? profileName : nickname }}</h6>
<div class="profile-detail">
<span>{{ date }}</span>
<template v-if="showDetail">
<span class="ms-2"> <i class="fa-regular fa-eye"></i> {{ views }} </span>
<span class="ms-1"> <i class="bx bx-comment"></i> {{ commentNum }} </span>
<span v-if="unknown" class="ms-1"> <i class="bx bx-comment"></i> {{ commentNum }} </span>
</template>
</div>
</div>
@ -20,28 +26,37 @@
<div class="ms-auto text-end">
<!-- 수정, 삭제 버튼 -->
<template v-if="!isDeletedComment && (unknown || isCommentAuthor || isAuthor)">
<EditButton @click.stop="editClick" />
<DeleteButton @click.stop="deleteClick" />
<div class="float-end ms-1">
<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>
<!-- 좋아요, 싫어요 버튼 (댓글에서만 표시) -->
<BoardRecommendBtn v-if="isLike" :boardId="boardId" :comment="comment" @updateReaction="handleUpdateReaction" />
<BoardRecommendBtn
v-if="isLike && !isDeletedComment"
:boardId="boardId"
:comment="comment"
:likeClicked="comment.likeClicked"
:dislikeClicked="comment.dislikeClicked"
@updateReaction="handleUpdateReaction"
/>
</div>
</div>
</template>
<script setup>
import { computed, defineProps, defineEmits } from 'vue';
import { computed, defineProps, defineEmits, inject, onMounted } from 'vue';
import DeleteButton from '../button/DeleteBtn.vue';
import EditButton from '../button/EditBtn.vue';
import BoardRecommendBtn from '../button/BoardRecommendBtn.vue';
//
const defaultProfile = '/img/icons/icon.png';
// (Vue )
const baseUrl = 'http://localhost:10325/'; // API URL
// Props
const props = defineProps({
comment: {
@ -60,7 +75,7 @@
type: String,
default: '',
},
profilePath: {
nickname: {
type: String,
default: '',
},
@ -94,9 +109,22 @@
type: Boolean,
default: false,
},
profileImg: {
type: String,
default: false,
},
isEditPushed: {
type: Boolean,
require: false,
},
isDeletePushed: {
type: Boolean,
require: false,
},
});
const emit = defineEmits(['updateReaction', 'editClick', 'deleteClick']);
const $common = inject('common');
const isDeletedComment = computed(() => {
return props.comment?.content === '삭제된 댓글입니다' && props.comment?.updateAtRaw !== props.comment?.createdAtRaw;
@ -122,7 +150,15 @@
};
//
const getProfileImage = profilePath => {
return profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile;
const getProfileImage = profileImg => {
return $common.getProfileImage(profileImg, props.unknown);
};
const setDefaultImage = e => {
return $common.setDefaultImage(e);
};
const showImage = e => {
return $common.showImage(e);
};
</script>

View File

@ -1,124 +1,89 @@
<template v-if="isRecommend">
<button class="btn btn-label-primary btn-icon" :class="{'clicked': likeClicked, 'big': bigBtn}" @click="handleLike">
<i class="fa-regular fa-thumbs-up"></i> <span class="num">{{ likeCount }}</span>
<button
class="btn btn-label-primary btn-icon me-1"
:class="{ clicked: likeClicked, big: bigBtn, active: props.likeClicked }"
@click="handleLike"
>
<i class="fa-regular fa-thumbs-up"></i> <span class="ms-1">{{ likeCount }}</span>
</button>
<button class="btn btn-label-danger btn-icon" :class="{'clicked': dislikeClicked, 'big': bigBtn}" @click="handleDislike">
<i class="fa-regular fa-thumbs-down"></i> <span class="num">{{ dislikeCount }}</span>
<button
class="btn btn-label-danger btn-icon"
:class="{ clicked: dislikeClicked, big: bigBtn, active: props.dislikeClicked }"
@click="handleDislike"
>
<i class="fa-regular fa-thumbs-down"></i> <span class="ms-1">{{ dislikeCount }}</span>
</button>
</template>
<script setup>
import { ref, computed } from 'vue';
import { ref, computed, watch } from 'vue';
const props = defineProps({
comment: {
type: Object,
default: () => ({}),
},
likeClicked : {
type : Boolean,
default : false,
},
dislikeClicked : {
type : Boolean,
default : false,
},
bigBtn : {
type :Boolean,
default : false,
},
isRecommend: {
type:Boolean,
default:true,
},
boardId: {
type: Number,
required: true,
},
commentId: {
type: [Number, null],
default: null,
},
likeCount: {
type: Number,
default: 0,
},
dislikeCount: {
type: Number,
default: 0,
},
});
const props = defineProps({
comment: {
type: Object,
default: () => ({}),
},
likeClicked: {
type: Boolean,
default: false,
},
dislikeClicked: {
type: Boolean,
default: false,
},
bigBtn: {
type: Boolean,
default: false,
},
isRecommend: {
type: Boolean,
default: true,
},
boardId: {
type: Number,
required: true,
},
commentId: {
type: [Number, null],
default: null,
},
likeCount: {
type: Number,
default: 0,
},
dislikeCount: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['updateReaction']);
const emit = defineEmits(['updateReaction']);
const likeClicked = ref(props.likeClicked);
const dislikeClicked = ref(props.dislikeClicked);
const likeCount = computed(() => props.comment?.likeCount ?? props.likeCount);
const dislikeCount = computed(() => props.comment?.dislikeCount ?? props.dislikeCount);
const likeClicked = ref(props.likeClicked);
const dislikeClicked = ref(props.dislikeClicked);
const likeCount = computed(() => props.comment?.likeCount ?? props.likeCount);
const dislikeCount = computed(() => props.comment?.dislikeCount ?? props.dislikeCount);
const handleLike = () => {
const isLike = !likeClicked.value;
const isDislike = false;
watch([() => props.likeClicked, () => props.dislikeClicked], ([likeNewval, dislikeNewval]) => {
likeClicked.value = likeNewval;
dislikeClicked.value = dislikeNewval;
});
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
likeClicked.value = isLike;
dislikeClicked.value = false;
};
const handleLike = () => {
const isLike = !likeClicked.value;
const isDislike = false;
const handleDislike = () => {
const isDislike = !dislikeClicked.value;
const isLike = false;
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
likeClicked.value = isLike;
dislikeClicked.value = false;
};
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
dislikeClicked.value = isDislike;
likeClicked.value = false;
};
const handleDislike = () => {
const isDislike = !dislikeClicked.value;
const isLike = false;
emit('updateReaction', { boardId: props.boardId, commentId: props.commentId, isLike, isDislike });
dislikeClicked.value = isDislike;
likeClicked.value = false;
};
</script>
<style scoped>
.btn + .btn {
margin-left: 5px;
}
.num {
margin-left: 5px;
}
.btn-label-danger.clicked {
background-color: #e6381a;
}
.btn-label-danger.clicked i,
.btn-label-danger.clicked span {
color: #fff;
}
.btn-label-primary.clicked {
background-color: #5f61e6;
}
.btn-label-primary.clicked i,
.btn-label-primary.clicked span {
color : #fff;
}
.btn {
width: 55px;
height: 30px;
}
.btn.big {
width: 70px;
height: 70px;
font-size: 18px;
}
@media screen and (max-width:450px) {
.btn {
width: 50px;
height: 20px;
font-size: 12px;
}
}
</style>

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,30 +1,38 @@
<template>
<button class="btn btn-label-primary btn-icon me-1" @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, defineProps } from 'vue';
import { ref, watch, defineEmits, watchEffect } from 'vue';
const props = defineProps({
isToggleEnabled: {
type: Boolean,
default: false,
},
isActive: {
type: Boolean,
required: false,
},
isPushed: {
type: Boolean,
required: false,
},
});
const emit = defineEmits(['click']);
const buttonClass = ref('bx bx-edit-alt');
const toggleText = () => {
watchEffect(() => {
buttonClass.value = props.isActive ? 'bx bx-x' : 'bx bx-edit-alt';
});
const toggleText = event => {
//
if (props.isToggleEnabled) {
buttonClass.value = buttonClass.value === 'bx bx-edit-alt' ? 'bx bx-x' : 'bx bx-edit-alt';
}
emit('click', event); //
};
const resetButton = () => {
buttonClass.value = 'bx bx-edit-alt';
};
defineExpose({ resetButton });
</script>

View File

@ -1,66 +1,72 @@
<template>
<div class="menu gap-4 justify-content-center mt-5">
<!-- 오전 반차 버튼 -->
<button
class="vac-btn vac-btn-warning"
:class="{ active: halfDayType === 'AM' }"
@click="toggleHalfDay('AM')"
>
<i class="bi bi-sun"></i>
</button>
<!-- 오후 반차 버튼 -->
<button
class="vac-btn vac-btn-info"
:class="{ active: halfDayType === 'PM' }"
@click="toggleHalfDay('PM')"
>
<i class="bi bi-moon"></i>
</button>
<!-- 저장 버튼 -->
<div class="save-button-container">
<button class="btn-success" @click="addVacationRequests"
<div class="row gx-2 mb-10 mt-1">
<div class="col-3">
<div class="ratio ratio-1x1">
<!-- 오전 반차 버튼 -->
<button class="vac-btn vac-btn-warning rounded-circle d-flex align-items-center justify-content-center"
:class="{ active: halfDayType === 'AM' }"
@click="toggleHalfDay('AM')">
<i class="bi bi-sun d-flex"></i>
</button>
</div>
</div>
<div class="col-3">
<div class="ratio ratio-1x1">
<!-- 오후 반차 버튼 -->
<button class="vac-btn vac-btn-info rounded-circle d-flex align-items-center justify-content-center"
:class="{ active: halfDayType === 'PM' }"
@click="toggleHalfDay('PM')">
<i class="bi bi-moon d-flex"></i>
</button>
</div>
</div>
<div class="col-3">
<div class="ratio ratio-1x1">
<!-- 연차 버튼 -->
<button class="vac-btn vac-btn-primary rounded-circle d-flex align-items-center justify-content-center"
:class="{ active: halfDayType === 'FULL' }"
@click="toggleHalfDay('FULL')">
<i class="bi bi-calendar d-flex"></i>
</button>
</div>
</div>
<div class="col-3">
<div class="ratio ratio-1x1">
<!-- 저장 버튼 -->
<button class="vac-btn-success rounded-circle d-flex align-items-center justify-content-center"
@click="addVacationRequests"
:class="{ active: !isDisabled, disabled: isDisabled }">
</button>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { defineEmits, ref, defineProps, watch } from "vue";
import { defineEmits, ref, defineProps } from "vue";
const props = defineProps({
isDisabled: Boolean,
selectedDate: String // props
isDisabled: Boolean
});
const emit = defineEmits(["toggleHalfDay", "addVacationRequests", "resetHalfDay"]);
const halfDayType = ref(null);
const toggleHalfDay = (type) => {
halfDayType.value = halfDayType.value === type ? null : type;
emit("toggleHalfDay", halfDayType.value);
halfDayType.value = halfDayType.value === type ? null : type;
emit("toggleHalfDay", halfDayType.value);
};
// `selectedDate`
watch(() => props.selectedDate, (newDate) => {
if (newDate) {
resetHalfDay();
}
});
//
//
const resetHalfDay = () => {
halfDayType.value = null;
emit("resetHalfDay");
halfDayType.value = null;
emit("resetHalfDay");
};
const addVacationRequests = () => {
emit("addVacationRequests");
emit("addVacationRequests");
};
defineExpose({ resetHalfDay });
</script>
<style scoped>
</style>

View File

@ -1,13 +1,14 @@
<template>
<button class="btn btn-label-primary btn-icon">
<i class="icon-base bx bx-plus"></i>
</button>
<slot>
<i class="icon-base bx bx-plus"></i>
</slot>
</button>
</template>
<script>
export default {
name: 'PlusButton',
methods: {
},
};
export default {
name: 'PlusButton',
methods: {},
};
</script>

View File

@ -1,23 +1,18 @@
<template>
<button
type="button"
class="btn btn-primary ms-1"
@click="$emit('click')"
:disabled="!isEnabled"
>
<button type="button" class="btn btn-primary ms-1" @click="$emit('click')" :disabled="!isEnabled">
<i class="bx bx-check"></i>
</button>
</template>
<script>
export default {
name: "SaveButton",
props: {
isEnabled: {
type: Boolean,
default: true, //
export default {
name: 'SaveButton',
props: {
isEnabled: {
type: Boolean,
default: true, //
},
},
},
emits: ["click"],
};
emits: ['click'],
};
</script>

View File

@ -0,0 +1,87 @@
<template>
<!-- 뒤로가기 -->
<button
v-if="canGoBack"
@click="goBack"
:disabled="!canGoBack"
:class="{ 'shifted': showButton }"
class="back-btn rounded-pill btn-icon btn-secondary position-fixed shadow z-5 border-0">
<i class='bx bx-chevron-left'></i>
</button>
<!-- 위로 -->
<button
@click="scrollToTop"
class="scroll-top-btn rounded-pill btn-icon btn-primary position-fixed shadow z-5 border-0"
:class="{ 'visible': showButton, 'hidden': !showButton }"
>
<i class='bx bx-chevron-up'></i>
</button>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
const showButton = ref(false);
const canGoBack = ref(false);
const route = useRoute();
const router = useRouter();
const loginPagePath = "/login"; //
//
const handleScroll = () => {
showButton.value = window.scrollY > 200;
};
//
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
//
const goBack = () => {
if (canGoBack.value) {
router.back();
}
};
//
const updateCanGoBack = () => {
const historyBack = router.options.history.state.back;
const previousPage = document.referrer;
// 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);
};
//
onMounted(() => {
window.addEventListener("scroll", handleScroll);
updateCanGoBack();
});
// `canGoBack`
watch(route, () => {
updateCanGoBack();
});
//
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
});
</script>

View File

@ -5,17 +5,28 @@
</template>
<script setup>
import { ref, defineProps, defineExpose } from 'vue';
import { ref, defineProps, defineExpose, watch } from 'vue';
const props = defineProps({
isToggleEnabled: {
type: Boolean,
default: false,
},
isActive: {
type: Boolean,
required: false,
},
});
const buttonClass = ref("bx bx-edit");
watch(
() => props.isActive,
newVal => {
buttonClass.value = newVal ? 'bx bx-x' : 'bx bx-edit';
},
);
const toggleText = () => {
if (props.isToggleEnabled) {
buttonClass.value = buttonClass.value === "bx bx-edit" ? "bx bx-x" : "bx bx-edit";

View File

@ -1,54 +1,65 @@
<template>
<ul class="cate-list list-unstyled d-flex flex-wrap mb-0">
<li v-if="showAll" class="mt-2 me-2">
<button
type="button"
class="btn"
:class="{
'btn-outline-primary': selectedCategory !== 'all',
'btn-primary': selectedCategory === 'all'
}"
@click="selectCategory('all')"
>
All
</button>
</li>
<li v-for="category in lists" :key="category.value" class="mt-2 me-2">
<button
type="button"
class="btn"
:class="{
'btn-outline-primary': category.value !== selectedCategory,
'btn-primary': category.value === selectedCategory
<button
type="button"
class="btn"
:class="{
'btn-outline-primary': category.value.toString() !== selectedCategory?.toString(),
'btn-primary': category.value.toString() === selectedCategory?.toString()
}"
@click="selectCategory(category.value)"
>
{{ category.label }}
</button>
@click="selectCategory(category.value)"
>
{{ category.label }}
</button>
</li>
</ul>
</template>
<script setup>
import { defineProps, ref } from 'vue';
import { defineProps, defineEmits, ref, watch } from 'vue';
// lists prop
const props = defineProps({
lists: {
type: Array,
required: true,
},
showAll: {
type: Boolean,
required: false,
},
selectedCategory: {
type: [String,Number],
default: null,
required: false,
},
});
//
const selectedCategory = ref(null);
const emit = defineEmits();
const selectedCategory = ref(props.selectedCategory);
const emit = defineEmits(['update:data']);
const selectCategory = (cate) => {
selectedCategory.value = selectedCategory.value === cate ? null : cate;
emit('update:data', selectedCategory.value);
};
watch(() => props.selectedCategory, (newVal) => {
selectedCategory.value = newVal;
});
</script>
<style scoped>
@media (max-width: 768px) {
.cate-list {
overflow-x: scroll;
flex-wrap: nowrap !important;
li {
flex: 0 0 auto;
}
}
}
</style>

View File

@ -1,247 +0,0 @@
<template>
<div class="container-xxl flex-grow-1 container-p-y">
<div class="card app-calendar-wrapper">
<div class="row g-0">
<div class="col-3 border-end text-center">
<div class="card-body pb-0">
<img v-if="user" :src="`${baseUrl}upload/img/profile/${user.profile}`" alt="Profile Image" class="w-px-50 h-auto rounded-circle" @error="$event.target.src = '/img/icons/icon.png'"/>
<p class="mt-2">
{{ user.name }}
</p>
<div class="row g-0">
<div class="col-6 pe-1">
<p>출근시간</p>
<button class="btn btn-outline-primary border-3 w-100 py-0">
<i class='bx bx-run fs-2'></i>
</button>
</div>
<div class="col-6 ps-1">
<p>퇴근시간</p>
<button class="btn btn-outline-secondary border-3 w-100 py-0">
<i class='bx bxs-door-open fs-2'></i>
</button>
</div>
<div v-for="post in project" :key="post.PROJCTSEQ" class="border border-2 mt-3" :style="`border-color: ${post.projctcolor} !important; color: ${post.projctcolor} !important;`">
{{ post.PROJCTNAM }}
</div>
</div>
</div>
</div>
<div class="col app-calendar-content">
<div class="card shadow-none border-0">
<div class="card-body pb-0">
<full-calendar
ref="fullCalendarRef"
:events="calendarEvents"
:options="calendarOptions"
defaultView="dayGridMonth"
class="flatpickr-calendar-only"
>
</full-calendar>
</div>
</div>
</div>
</div>
</div>
</div>
<center-modal :display="isModalVisible" @close="isModalVisible = $event">
<template #title> Add Event </template>
<template #body>
<FormInput
title="이벤트 제목"
name="event"
:is-essential="true"
:is-alert="eventAlert"
@update:data="eventTitle = $event"
/>
<FormInput
title="이벤트 날짜"
type="date"
name="eventDate"
:is-essential="true"
:is-alert="eventDateAlert"
@update:data="eventDate = $event"
/>
</template>
<template #footer>
<button @click="addEvent">추가</button>
</template>
</center-modal>
</template>
<script setup>
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import CenterModal from '@c/modal/CenterModal.vue';
import { inject, onMounted, reactive, ref, watch } from 'vue';
import $api from '@api';
import { isEmpty } from '@/common/utils';
import FormInput from '../input/FormInput.vue';
import 'flatpickr/dist/flatpickr.min.css';
import '@/assets/css/app-calendar.css';
import { fetchHolidays } from '@c/calendar/holiday';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useProjectStore } from '@/stores/useProjectStore';
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const user = ref({});
const project = ref({});
const userStore = useUserInfoStore();
const projectStore = useProjectStore();
const dayjs = inject('dayjs');
const fullCalendarRef = ref(null);
const calendarEvents = ref([]);
const isModalVisible = ref(false);
const eventAlert = ref(false);
const eventDateAlert = ref(false);
const eventTitle = ref('');
const eventDate = ref('');
const selectedDate = ref(null);
//
const handleDateSelect = (selectedDates) => {
if (selectedDates.length > 0) {
// YYYY-MM-DD
const selectedDate = dayjs(selectedDates[0]).format('YYYY-MM-DD');
eventDate.value = selectedDate;
showModal(); //
}
};
//
const fetchData = async () => {
// FullCalendar API
const calendarApi = fullCalendarRef.value?.getApi();
if (!calendarApi) return;
// ,
const date = calendarApi.currentData.viewTitle;
const dateArr = date.split(' ');
let currentYear = dateArr[0].trim();
let currentMonth = dateArr[1].trim();
const regex = /\D/g;
//
currentYear = parseInt(currentYear.replace(regex, ''), 10);
currentMonth = parseInt(currentMonth.replace(regex, ''), 10);
try {
//
const holidayEvents = await fetchHolidays(currentYear, String(currentMonth).padStart(2, '0'));
//
const existingEvents = calendarEvents.value.filter(event => !event.classNames?.includes('holiday-event'));
//
calendarEvents.value = [...existingEvents, ...holidayEvents];
} catch (error) {
console.error('공휴일 정보 로딩 실패:', error);
}
};
// (, , )
const moveCalendar = async (value = 0) => {
const calendarApi = fullCalendarRef.value?.getApi();
if (value === 1) {
calendarApi.prev(); //
} else if (value === 2) {
calendarApi.next(); //
} else if (value === 3) {
calendarApi.today(); //
}
//
await fetchData();
};
//
const showModal = () => {
isModalVisible.value = true;
};
//
const closeModal = () => {
isModalVisible.value = false;
//
eventTitle.value = '';
eventDate.value = '';
};
//
const addEvent = () => {
//
if (!checkEvent()) {
//
calendarEvents.value.push({
title: eventTitle.value,
start: eventDate.value,
backgroundColor: '#4CAF50' //
});
closeModal(); //
}
};
//
const checkEvent = () => {
//
eventAlert.value = isEmpty(eventTitle.value);
eventDateAlert.value = isEmpty(eventDate.value);
// true ( )
return eventAlert.value || eventDateAlert.value;
};
//
const calendarOptions = reactive({
plugins: [dayGridPlugin, interactionPlugin], //
initialView: 'dayGridMonth', // ()
headerToolbar: { //
left: 'today', // :
center: 'title', // : ()
right: 'prev,next', // : /
},
locale: 'kr', //
events: calendarEvents, //
eventOrder: 'sortIdx', //
selectable: true, //
dateClick: handleDateSelect, //
droppable: false, //
eventDisplay: 'block', //
//
customButtons: {
prev: {
text: 'PREV', //
click: () => moveCalendar(1), //
},
today: {
text: 'TODAY', //
click: () => moveCalendar(3), //
},
next: {
text: 'NEXT', //
click: () => moveCalendar(2), //
},
},
});
// ( )
watch(() => fullCalendarRef.value?.getApi().currentData.viewTitle, async () => {
await fetchData();
});
console.log(project)
onMounted(async () => {
await fetchData();
await userStore.userInfo();
user.value = userStore.user;
await projectStore.getProjectList();
project.value = projectStore.projectList;
});
</script>

View File

@ -0,0 +1,223 @@
<template>
<div class="row g-0">
<div class="col-6 pe-1">
<button
class="btn border-3 w-100 py-0 h-px-50"
:class="workTime ? 'p-0 btn-primary pe-none' : 'btn-outline-primary'"
@click="setWorkTime"
>
<i v-if="!workTime" class="bx bx-run fs-2"></i>
<span v-if="workTime" class="ql-size-12px">{{ workTime }}</span>
</button>
</div>
<div class="col-6 ps-1">
<button
class="btn border-3 w-100 py-0 h-px-50"
:class="!workTime ? 'btn-outline-secondary pe-none disabled' : 'btn-outline-secondary'"
@click="setLeaveTime"
:disabled="!workTime"
>
<i v-if="!leaveTime" class='bx bxs-door-open fs-2'></i>
<span v-if="leaveTime" class="ql-size-12px">{{ leaveTime }}</span>
</button>
</div>
</div>
</template>
<script setup>
import { ref, defineProps, defineEmits, onMounted, watch } from 'vue';
import $api from '@api';
import { useGeolocation } from '@vueuse/core';
const props = defineProps({
userId: {
type: Number,
required: false
},
checkedInProject: {
type: Object,
required: false
},
pendingProjectChange: {
type: Object,
default: null
}
});
const emit = defineEmits(['workTimeUpdated', 'leaveTimeUpdated', 'projectChangeComplete', 'update:pendingProjectChange']);
const workTime = ref(null);
const leaveTime = ref(null)
const userLocation = ref(null);
//
const { coords, isSupported, error } = useGeolocation({
enableHighAccuracy: true,
});
//
const getAddress = async (lat, lng) => {
return new Promise((resolve, reject) => {
if (typeof kakao === 'undefined' || !kakao.maps) {
reject('Kakao Maps API가 로드되지 않았습니다.');
return;
}
const geocoder = new kakao.maps.services.Geocoder();
geocoder.coord2Address(lng, lat, (result, status) => {
if (status === kakao.maps.services.Status.OK) {
if (result && result.length > 0 && result[0].address) {
const address = result[0].address.address_name;
resolve(address);
} else {
//
reject('주소 정보가 없습니다.');
}
} else if (status === kakao.maps.services.Status.ZERO_RESULT) {
// ZERO_RESULT
//
geocoder.coord2RegionCode(lng, lat, (regionResult, regionStatus) => {
if (regionStatus === kakao.maps.services.Status.OK && regionResult.length > 0) {
//
const region = regionResult[0].address_name;
resolve(`[대략적 위치] ${region}`);
} else {
//
resolve(`위도: ${lat}, 경도: ${lng} (주소 정보 없음)`);
}
});
} else {
reject(`주소를 가져올 수 없습니다. 상태: ${status}`);
}
});
});
};
//
const getLocation = async () => {
if (!isSupported.value) {
alert('브라우저가 위치 정보를 지원하지 않습니다.');
return null;
}
if (error.value) {
alert(`위치 정보를 가져오는데 실패했습니다: ${error.value.message}`);
return null;
}
if (!coords.value) {
return null;
}
userLocation.value = {
lat: coords.value.latitude,
lng: coords.value.longitude,
};
try {
const address = await getAddress(coords.value.latitude, coords.value.longitude);
return address;
} catch (error) {
alert(error);
return null;
}
};
//
const todayCommuterInfo = async () => {
if (!props.userId) return;
const res = await $api.get(`commuters/today/${props.userId}`);
if (res.status === 200) {
const commuterInfo = res.data.data[0];
if (commuterInfo) {
workTime.value = commuterInfo.COMMUTCMT;
leaveTime.value = commuterInfo.COMMUTLVE;
//
emit('workTimeUpdated', workTime.value);
emit('leaveTimeUpdated', leaveTime.value);
}
}
};
//
const setWorkTime = async () => {
//
if (workTime.value) return;
//
const address = await getLocation();
if (!address) {
//
if (!confirm('위치 정보를 가져오지 못했습니다. 위치 없이 출근 처리하시겠습니까?')) {
return;
}
}
$api.post('commuters/insert', {
memberSeq: props.userId,
projctSeq: props.checkedInProject.PROJCTSEQ,
commutLve: null,
commutArr: address,
commutOut: null,
}).then(res => {
if (res.status === 200) {
todayCommuterInfo();
emit('workTimeUpdated', true);
}
});
};
//
const setLeaveTime = async () => {
//
const address = await getLocation();
if (!address && !leaveTime.value) {
//
if (!confirm('위치 정보를 가져오지 못했습니다. 위치 없이 퇴근 처리하시겠습니까?')) {
return;
}
}
$api.patch('commuters/updateLve', {
memberSeq: props.userId,
commutLve: leaveTime.value || null,
projctLve: props.pendingProjectChange ? props.pendingProjectChange.projctSeq : props.checkedInProject.PROJCTSEQ,
commutOut: address,
}).then(res => {
if (res.status === 200) {
todayCommuterInfo();
emit('leaveTimeUpdated');
emit('update:pendingProjectChange', null);
}
});
};
// props
watch(() => props.userId, async () => {
if (props.userId) {
await todayCommuterInfo();
}
});
onMounted(async () => {
await todayCommuterInfo();
});
//
defineExpose({
todayCommuterInfo,
workTime,
leaveTime
});
</script>

View File

@ -0,0 +1,520 @@
<template>
<div class="container-xxl flex-grow-1 container-p-y">
<div class="card app-calendar-wrapper">
<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 object-fit-cover" @error="$event.target.src = '/img/icons/icon.png'"/>
<p class="mt-2 fw-bold">
{{ user.name }}
</p>
<CommuterBtn
:userId="user.id"
:checkedInProject="checkedInProject || {}"
:pendingProjectChange="pendingProjectChange"
@update:pendingProjectChange="pendingProjectChange = $event"
@workTimeUpdated="handleWorkTimeUpdate"
@leaveTimeUpdated="handleLeaveTimeUpdate"
ref="workTimeComponentRef"
/>
<CommuterProjectList
:project="project"
:commuters="commuters"
:baseUrl="baseUrl"
:user="user"
:selectedProject="selectedProject"
:checkedInProject="checkedInProject"
@drop="handleProjectDrop"
/>
</div>
</div>
<div class="col app-calendar-content">
<div class="card shadow-none border-0">
<div class="card-body">
<full-calendar
ref="fullCalendarRef"
:events="calendarEvents"
:options="calendarOptions"
defaultView="dayGridMonth"
class="flatpickr-calendar-only"
>
</full-calendar>
<input ref="calendarDatepicker" type="text" class="d-none" />
</div>
</div>
</div>
</div>
</div>
</div>
<CenterModal :display="isModalOpen" @close="closeModal">
<template #title>
{{ eventDate }}
</template>
<template #body>
<div v-if="selectedDateCommuters.length > 0">
<div v-for="(commuter, index) in selectedDateCommuters" :key="index">
<div class="row my-2 d-flex align-items-center">
<div class="col-4">
<img :src="`${baseUrl}upload/img/profile/${commuter.profile}`"
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>
</div>
<div class="col-8">
<div class="d-flex gap-1 align-items-center">
출근 :
<MapPopover
:address="commuter.projectAddress"
:is-visible="visiblePopover.type === 'project' && visiblePopover.index === index"
@update-visible="updatePopover('project', index)"
v-if="commuter.projectAddress"
>
<template #trigger>
<div
class="text-white rounded px-2 cursor-pointer"
:style="`background: ${commuter.projctcolor} !important;`"
>
{{ commuter.PROJCTNAM }}
</div>
</template>
</MapPopover>
<span class="ms-auto">
({{ commuter.COMMUTCMT }})
</span>
</div>
<div v-if="commuter.PROJCTLVE" class="d-flex gap-1 mt-1">
퇴근 :
<MapPopover
:address="commuter.leaveProjectAddress"
:is-visible="visiblePopover.type === 'leave' && visiblePopover.index === index"
@update-visible="updatePopover('leave', index)"
v-if="commuter.leaveProjectAddress"
>
<template #trigger>
<div
class="text-white rounded px-2 cursor-pointer"
:style="`background: ${commuter.leaveProjectColor} !important;`"
>
{{ commuter.leaveProjectName }}
</div>
</template>
</MapPopover>
<span class="ms-auto">
({{ commuter.COMMUTLVE || "00:00:00" }})
</span>
</div>
</div>
</div>
</div>
</div>
</template>
<template #footer>
<BackBtn @click="closeModal" />
</template>
</CenterModal>
</template>
<script setup>
import FullCalendar from '@fullcalendar/vue3';
import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction';
import CenterModal from '@c/modal/CenterModal.vue';
import { computed, inject, nextTick, onMounted, reactive, ref, watch } from 'vue';
import $api from '@api';
import 'flatpickr/dist/flatpickr.min.css';
import '@/assets/css/app-calendar.css';
import { fetchHolidays } from '@c/calendar/holiday';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useProjectStore } from '@/stores/useProjectStore';
import CommuterBtn from '@c/commuters/CommuterBtn.vue';
import CommuterProjectList from '@c/commuters/CommuterProjectList.vue';
import BackBtn from '@c/button/BackBtn.vue';
import MapPopover from '@c/map/MapPopover.vue';
import { useDatePicker } from '@/stores/useDatePicker';
const datePickerStore = useDatePicker();
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const user = ref({});
const project = ref({});
const userStore = useUserInfoStore();
const projectStore = useProjectStore();
const dayjs = inject('dayjs');
const fullCalendarRef = ref(null);
const workTimeComponentRef = ref(null);
const calendarEvents = ref([]);
const eventDate = ref('');
const selectedProject = ref(null);
const checkedInProject = ref(null);
const isModalOpen = ref(false);
const visiblePopover = ref({
type: null, // 'project' 'leave'
index: null //
});
const commuters = ref([]);
const monthlyCommuters = ref([]);
const calendarDatepicker = ref(null);
const pendingProjectChange = ref(null);
//
const handleWorkTimeUpdate = () => {
todaysCommuter();
loadCommuters();
};
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 )
const handleProjectDrop = ({ event, targetProject }) => {
const draggedProjectData = JSON.parse(event.dataTransfer.getData('application/json'));
//
if (draggedProjectData.PROJCTSEQ === targetProject.PROJCTSEQ) {
return;
}
pendingProjectChange.value = {
projctSeq: targetProject.PROJCTSEQ,
memberSeq: user.value.id
};
checkedInProject.value = targetProject;
selectedProject.value = targetProject.PROJCTSEQ;
projectStore.setSelectedProject(targetProject);
// UI
commuters.value = commuters.value.map(commuter =>
commuter.MEMBERSEQ === user.value.id
? { ...commuter, PROJCTNAM: targetProject.PROJCTNAM, PROJCTLVE: targetProject.PROJCTSEQ }
: commuter
);
// CommuterBtn (/ )
if (workTimeComponentRef.value && workTimeComponentRef.value.fetchWorkTime) {
workTimeComponentRef.value.fetchWorkTime();
}
};
//
const todaysCommuter = async () => {
const res = await $api.get(`commuters/todays`);
if (res.status === 200 ) {
commuters.value = res.data.data;
}
};
//
const fetchData = async () => {
// FullCalendar API
const calendarApi = fullCalendarRef.value?.getApi();
if (!calendarApi) return;
// ,
const date = calendarApi.currentData.viewTitle;
const dateArr = date.split(' ');
let currentYear = dateArr[0].trim();
let currentMonth = dateArr[1].trim();
const regex = /\D/g;
//
currentYear = parseInt(currentYear.replace(regex, ''), 10);
currentMonth = parseInt(currentMonth.replace(regex, ''), 10);
try {
//
const holidayEvents = await fetchHolidays(currentYear, String(currentMonth).padStart(2, '0'));
//
const existingEvents = calendarEvents.value.filter(event => !event.classNames?.includes('holiday-event'));
//
calendarEvents.value = [...existingEvents, ...holidayEvents];
//
await loadCommuters();
} catch (error) {
console.error('공휴일 정보 로딩 실패:', error);
}
};
// (, , )
const moveCalendar = async (value = 0) => {
const calendarApi = fullCalendarRef.value?.getApi();
if (value === 1) {
calendarApi.prev(); //
} else if (value === 2) {
calendarApi.next(); //
} else if (value === 3) {
calendarApi.today(); //
}
await fetchData();
};
//
const isSelectableDate = (date) => {
const checkDate = dayjs(date);
const isWeekend = checkDate.day() === 0 || checkDate.day() === 6;
//
const isHoliday = calendarEvents.value.some(event =>
event.classNames?.includes('holiday-event') &&
dayjs(event.start).format('YYYY-MM-DD') === checkDate.format('YYYY-MM-DD')
);
return !isWeekend && !isHoliday;
};
//
let todayElement = null;
const handleDateClick = (info) => {
const clickedDate = dayjs(info.date).format('YYYY-MM-DD');
//
const dateCommuters = monthlyCommuters.value.filter(commuter =>
commuter.COMMUTDAY === clickedDate
);
//
if (dateCommuters.length > 0) {
eventDate.value = clickedDate;
isModalOpen.value = true;
}
if (isSelectableDate(info.date)) {
const isToday = dayjs(info.date).isSame(dayjs(), 'day');
if (isToday) {
//
todayElement = info.dayEl;
todayElement.classList.remove('fc-day-today');
} else if (todayElement) {
//
todayElement.classList.add('fc-day-today');
todayElement = null;
}
}
};
// todayElement
document.addEventListener('click', (event) => {
if (todayElement && !event.target.closest('.fc-daygrid-day')) {
todayElement.classList.add('fc-day-today');
todayElement = null;
}
}, true);
//
const getCellClassNames = (arg) => {
const cellDate = dayjs(arg.date);
const classes = [];
// (, , )
if (!isSelectableDate(cellDate)) {
classes.push('fc-day-sat-sun');
}
return classes;
};
//
const loadCommuters = async () => {
const calendarApi = fullCalendarRef.value?.getApi();
if (!calendarApi) return;
const date = calendarApi.currentData.viewTitle;
const dateArr = date.split(' ');
let currentYear = dateArr[0].trim();
let currentMonth = dateArr[1].trim();
const regex = /\D/g;
currentYear = parseInt(currentYear.replace(regex, ''), 10);
currentMonth = parseInt(currentMonth.replace(regex, ''), 10);
const res = await $api.get('commuters/month', {
params: {
year: currentYear,
month: currentMonth
}
});
if (res.status === 200) {
//
monthlyCommuters.value = res.data.data;
document.querySelectorAll('.fc-daygrid-day-events img.rounded-circle').forEach(img => {
img.remove();
});
monthlyCommuters.value.forEach(commuter => {
const date = commuter.COMMUTDAY;
const dateCell = document.querySelector(`.fc-day[data-date="${date}"]`) ||
document.querySelector(`.fc-daygrid-day[data-date="${date}"]`);
if (dateCell) {
const dayEvents = dateCell.querySelector('.fc-daygrid-day-events');
if (dayEvents) {
dateCell.setAttribute('data-has-commuters', 'true');
dayEvents.classList.add('text-center');
//
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 object-fit-cover';
profileImg.style.border = `2px solid ${commuter.projctcolor}`;
profileImg.onerror = () => { profileImg.src = '/img/icons/icon.png'; };
dayEvents.appendChild(profileImg);
}
}
});
}
};
//
const calendarOptions = reactive({
plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
headerToolbar: {
left: 'today',
center: 'title',
right: 'prev,next',
},
locale: 'kr',
events: calendarEvents,
eventOrder: 'sortIdx',
contentHeight:"auto",
// eventContent: calendarCommuter,
//
selectable: true,
selectAllow: (selectInfo) => isSelectableDate(selectInfo.start),
dateClick: handleDateClick,
dayCellClassNames: getCellClassNames,
//
unselectAuto: true,
droppable: false,
eventDisplay: 'block',
//
customButtons: {
prev: {
text: 'PREV',
click: () => moveCalendar(1),
},
today: {
text: 'TODAY',
click: () => moveCalendar(3),
},
next: {
text: 'NEXT',
click: () => moveCalendar(2),
},
},
});
// ( )
watch(() => fullCalendarRef.value?.getApi().currentData.viewTitle, async () => {
await fetchData();
});
// selectedProject
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
const updatePopover = (popoverType, index) => {
if (visiblePopover.value.type === popoverType && visiblePopover.value.index === index) {
//
visiblePopover.value = { type: null, index: null };
} else {
//
visiblePopover.value = { type: popoverType, index: index };
}
};
const selectedDateCommuters = computed(() => {
return monthlyCommuters.value.filter(commuter =>
commuter.COMMUTDAY === eventDate.value
);
});
onMounted(async () => {
await fetchData();
await userStore.userInfo();
user.value = userStore.user;
await projectStore.getProjectList('', '', 'true');
project.value = projectStore.activeProjectList;
await todaysCommuter();
//
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(
fullCalendarRef,
async (year, month, options) => {
//
await fetchData();
}
);
});
</script>

View File

@ -0,0 +1,111 @@
<template>
<div class="commuter-list mt-3">
<div
v-for="post in sortedProjects"
:key="post.PROJCTSEQ"
class="border border-2 mb-3 card p-2"
:style="`border-color: ${post.projctcolor} !important; color: ${post.projctcolor} !important;`"
@dragover="allowDrop($event)"
@drop="handleDrop($event, post)"
>
<p class="mb-1">
{{ post.PROJCTNAM }}
</p>
<div class="row gx-2">
<div
v-for="commuter in commuters.filter(c =>
(c.PROJCTLVE ? c.PROJCTLVE === post.PROJCTSEQ : c.PROJCTNAM === post.PROJCTNAM)
)"
:key="commuter.MEMBERSEQ"
class="col-4"
>
<div class="ratio ratio-1x1">
<img
:src="`${baseUrl}upload/img/profile/${commuter.profile}`"
alt="User Profile"
class="rounded-circle object-fit-cover"
:class="isCurrentUser(commuter) ? 'cursor-pointer' : ''"
:draggable="isCurrentUser(commuter)"
@dragstart="isCurrentUser(commuter) ? dragStart($event, post) : null"
@error="$event.target.src = '/img/icons/icon.png'"
>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, defineEmits, computed } from 'vue';
const props = defineProps({
project: {
type: Object,
required: false
},
commuters: {
type: Array,
required: false
},
baseUrl: {
type: String,
required: false
},
user: {
type: Object,
required: false
},
selectedProject: {
type: Number,
default: null
},
checkedInProject: {
type: Object,
default: null
}
});
const emit = defineEmits(['drop', 'update:selectedProject', 'update:checkedInProject']);
//
const sortedProjects = computed(() => {
const projectList = Array.isArray(props.project) ? props.project :
Object.values(props.project || {});
return projectList
.filter(item => item && typeof item === 'object')
.sort((a, b) => (b.participant_count || 0) - (a.participant_count || 0));
});
//
const isCurrentUser = (commuter) => {
//
const isCurrentUserCheck = props.user && commuter && commuter.MEMBERSEQ === props.user.id;
//
const hasNoCheckRecord = !commuter.COMMUTLVE;
// true
return isCurrentUserCheck && hasNoCheckRecord;
};
//
const dragStart = (event, project) => {
//
event.dataTransfer.setData('application/json', JSON.stringify(project));
event.dataTransfer.effectAllowed = 'copy';
};
//
const allowDrop = (event) => {
event.preventDefault();
};
//
const handleDrop = (event, targetProject) => {
event.preventDefault();
emit('drop', { event, targetProject });
};
</script>

View File

@ -41,11 +41,12 @@
<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>
<!-- 에디터가 표시될 div -->
<div ref="editor"></div>
<div id="qEditor" ref="editor"></div>
<!-- Alert 메시지 표시 -->
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">내용을 확인해주세요.</div>
</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: {
@ -71,7 +75,10 @@
const editor = ref(null); // DOM
const font = ref('nanum-gothic'); //
const fontSize = ref('16px'); //
const emit = defineEmits(['update:data']);
const emit = defineEmits(['update:data', 'update:uploadedImgList', 'update:deleteImgIndexList']);
const uploadedImgList = ref([]); //
const initImageIndex = ref([]); //
const deleteImgIndexList = ref([]); //
onMounted(() => {
//
@ -112,12 +119,54 @@
quillInstance.format('size', fontSize.value);
});
//
watch(uploadedImgList, () => {
emit('update:uploadedImgList', uploadedImgList.value);
});
// ()
watch(deleteImgIndexList, () => {
emit('update:deleteImgIndexList', deleteImgIndexList.value);
});
// , HTML
if (props.initialData) {
console.log(props.initialData);
quillInstance.setContents(JSON.parse(props.initialData));
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', () => {
@ -126,7 +175,6 @@
//
quillInstance.on('text-change', (delta, oldDelta, source) => {
emit('update:data', quillInstance.getContents());
delta.ops.forEach(op => {
if (op.insert && typeof op.insert === 'object' && op.insert.image) {
const imageUrl = op.insert.image; // URL
@ -135,7 +183,11 @@
checkForDeletedImages(); //
}
});
checkDeletedImages();
emit('update:data', quillInstance.getContents());
});
//
async function selectLocalImage() {
const input = document.createElement('input');
@ -150,9 +202,19 @@
// URL
uploadImageToServer(formData)
.then(serverImageUrl => {
.then(data => {
const uploadImgIdx = data?.fileIndex; // DB
const serverImageUrl = data?.fileUrl; // url
// ( )
if (uploadImgIdx) {
uploadedImgList.value = [...uploadedImgList.value, uploadImgIdx];
initImageIndex.value = [...initImageIndex.value, uploadImgIdx];
}
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}`;
//const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}`;
const fullImageUrl = `${baseUrl}${serverImageUrl.replace(/\\/g, '/')}?imgIndex=${uploadImgIdx}`; // index
const range = quillInstance.getSelection();
quillInstance.insertEmbed(range.index, 'image', fullImageUrl); //
@ -165,21 +227,47 @@
}
};
}
//
async function uploadImageToServer(formData) {
try {
// Make the POST request to upload the image
const response = await $api.post('quilleditor/upload', formData, { isFormData: true });
const imageUrl = response.data.data;
return imageUrl; // URL
// Check if the response contains the expected data
if (response.data && response.data.data) {
const imageUrl = response.data.data;
return imageUrl; // Return the image URL received from the server
} else {
throw new Error('Image URL not returned from server');
}
} catch (error) {
toastStore.onToast('잠시후 다시 시도해주세요.', 'e');
// Log detailed error information for debugging purposes
console.error('Image upload failed:', error);
// Handle specific error cases (e.g., network issues, authorization issues)
if (error.response) {
// If the error is from the server (e.g., 4xx or 5xx error)
console.error('Error response:', error.response.data);
toastStore.onToast('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', 'e');
} else if (error.request) {
// If no response is received from the server
console.error('No response received:', error.request);
toastStore.onToast('네트워크 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', 'e');
} else {
// If the error is due to something else (e.g., invalid request setup)
console.error('Error message:', error.message);
toastStore.onToast('파일 업로드 중 문제가 발생했습니다. 다시 시도해주세요.', 'e');
}
// Throw the error so the caller knows something went wrong
throw error;
}
}
//
function checkForDeletedImages() {
const editorImages = document.querySelectorAll('#editor img');
const editorImages = document.querySelectorAll('#qEditor img');
const currentImages = new Set(Array.from(editorImages).map(img => img.src)); //
imageUrls.forEach(url => {
@ -188,12 +276,41 @@
}
});
}
//
function initCheckImageIndex() {
const editorImages = document.querySelectorAll('#qEditor img');
const currentImages = new Set(Array.from(editorImages).map(img => img.src)); //
currentImages.forEach(url => {
const index = getImgIndex(url);
if (index) {
initImageIndex.value.push(Number(index));
}
});
}
// index
function getImgIndex(url) {
const params = new URLSearchParams(url.split('?')[1]);
return params.get('imgIndex');
}
//
function checkDeletedImages() {
const editorImages = document.querySelectorAll('#qEditor img');
const currentImages = new Set(Array.from(editorImages).map(img => img.src));
// init
const tempDeleteImgIndex = [...initImageIndex.value];
currentImages.forEach(url => {
const imgIndex = getImgIndex(url);
if (imgIndex) {
const index = tempDeleteImgIndex.indexOf(imgIndex);
tempDeleteImgIndex.splice(index, 1);
}
});
deleteImgIndexList.value = tempDeleteImgIndex;
}
});
</script>
<style>
@import 'quill/dist/quill.snow.css';
.ql-editor {
min-height: 300px;
font-family: 'Nanum Gothic', sans-serif;
}
</style>

View File

@ -18,6 +18,7 @@
type="text"
v-model="postcode"
placeholder="우편번호"
disabled="true"
readonly
/>
@ -26,6 +27,7 @@
type="text"
v-model="address"
placeholder="기본주소"
disabled="true"
readonly
/>
</div>

View File

@ -2,11 +2,14 @@
<div class="mb-4 row">
<label :for="inputId" class="col-md-2 col-form-label">{{ title }}</label>
<div class="col-md-10">
<label :for="inputId" class="btn btn-label-primary">파일 선택</label>
<input
class="form-control"
type="file"
style="display: none"
:id="inputId"
ref="fileInput"
:key="autoIncrement"
@change="changeHandler"
multiple
/>
@ -18,69 +21,71 @@
</template>
<script setup>
import { ref ,computed} from 'vue';
import { fileMsg } from '@/common/msgEnum';
import { ref, computed } from 'vue';
import { fileMsg } from '@/common/msgEnum';
// Props
const props = defineProps({
title: {
type: String,
default: '라벨',
required: true,
},
name: {
type: String,
default: 'fileInput',
required: true,
},
isAlert: {
type: Boolean,
default: false,
required: false,
},
});
const inputId = computed(() => props.name || 'defaultFileInput');
// Props
const props = defineProps({
title: {
type: String,
default: '라벨',
required: true,
},
name: {
type: String,
default: 'fileInput',
required: true,
},
isAlert: {
type: Boolean,
default: false,
required: false,
},
});
//:key="autoIncrement" .
const emits = defineEmits(['update:data', 'update:isValid']);
const inputId = computed(() => props.name || 'defaultFileInput');
const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_FILE_COUNT = 5; //
const ALLOWED_FILE_TYPES = []; //
const emits = defineEmits(['update:data', 'update:isValid']);
const showError = ref(false);
const fileMsgKey = ref(''); //
const autoIncrement = ref(props.autoIncrement);
const changeHandler = (event) => {
const files = Array.from(event.target.files);
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
const MAX_TOTAL_SIZE = 5 * 1024 * 1024; // 5MB
const MAX_FILE_COUNT = 5; //
const ALLOWED_FILE_TYPES = []; //
// ALLOWED_FILE_TYPES
const invalidFiles = ALLOWED_FILE_TYPES.length > 0
? files.filter(file => !ALLOWED_FILE_TYPES.includes(file.type))
: [];
const showError = ref(false);
const fileMsgKey = ref(''); //
if (totalSize > MAX_TOTAL_SIZE) {
showError.value = true;
fileMsgKey.value = 'FileMaxSizeMsg';
emits('update:data', []);
emits('update:isValid', false);
} else if (files.length > MAX_FILE_COUNT) {
showError.value = true;
fileMsgKey.value = 'FileMaxLengthMsg';
emits('update:data', []);
emits('update:isValid', false);
} else if (invalidFiles.length > 0) {
showError.value = true;
fileMsgKey.value = 'FileNotTypeMsg';
emits('update:data', []);
emits('update:isValid', false);
} else {
showError.value = false;
fileMsgKey.value = '';
emits('update:data', files);
emits('update:isValid', true);
}
};
const changeHandler = event => {
const files = Array.from(event.target.files);
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
const errorMessage = computed(() => (fileMsg[fileMsgKey.value] || ''));
// ALLOWED_FILE_TYPES
const invalidFiles = ALLOWED_FILE_TYPES.length > 0 ? files.filter(file => !ALLOWED_FILE_TYPES.includes(file.type)) : [];
if (totalSize > MAX_TOTAL_SIZE) {
showError.value = true;
fileMsgKey.value = 'FileMaxSizeMsg';
emits('update:data', []);
emits('update:isValid', false);
} else if (files.length > MAX_FILE_COUNT) {
showError.value = true;
fileMsgKey.value = 'FileMaxLengthMsg';
emits('update:data', []);
emits('update:isValid', false);
} else if (invalidFiles.length > 0) {
showError.value = true;
fileMsgKey.value = 'FileNotTypeMsg';
emits('update:data', []);
emits('update:isValid', false);
} else {
showError.value = false;
fileMsgKey.value = '';
emits('update:data', files);
emits('update:isValid', true);
}
};
const errorMessage = computed(() => fileMsg[fileMsgKey.value] || '');
</script>

View File

@ -5,25 +5,26 @@
<span v-if="isEssential" class="text-danger">*</span>
</label>
<div class="col-md-10">
<input
:id="name"
class="form-control"
:type="type"
v-model="inputValue"
:maxLength="maxlength"
:placeholder="title"
:disabled="disabled"
:min="min"
@focusout="$emit('focusout', modelValue)"
@input="handleInput"
/>
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">
{{ title }} 확인해주세요.
</div>
<!-- 카테고리 중복 -->
<div class="invalid-feedback" :class="isCateAlert ? 'd-block' : ''">
카테고리 중복입니다.
<div class="d-flex align-items-center">
<input
:id="name"
class="form-control"
:type="type"
v-model="inputValue"
:maxLength="maxlength"
:placeholder="title"
:disabled="disabled"
:min="min"
autocomplete="off"
@focusout="$emit('focusout', modelValue)"
@input="handleInput"
/>
<div v-if="isBtn" class="ms-2">
<slot name="append"></slot>
</div>
</div>
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div>
<div class="invalid-feedback" :class="isCateAlert ? 'd-block' : ''">카테고리 중복입니다.</div>
</div>
</div>
</template>
@ -31,84 +32,89 @@
<script setup>
import { ref, watch } from 'vue';
// Props
const props = defineProps({
title: {
type: String,
default: '라벨',
required: true,
},
name: {
type: String,
default: 'nameplz',
required: true,
},
isEssential: {
type: Boolean,
default: false,
},
type: {
type: String,
default: 'text',
},
modelValue: {
type: String,
default: '',
},
maxlength: {
type: Number,
default: 30,
},
isAlert: {
type: Boolean,
default: false,
},
isCateAlert : {
type :Boolean,
default: false,
},
isLabel : {
type: Boolean,
default: true,
required: false,
},
disabled: {
type: Boolean,
default: false,
},
min: {
type: String,
default: '',
required: false,
}
});
// Props
const props = defineProps({
title: {
type: String,
default: '라벨',
required: true,
},
name: {
type: String,
default: 'nameplz',
required: true,
},
isEssential: {
type: Boolean,
default: false,
},
type: {
type: String,
default: 'text',
},
modelValue: {
type: String,
default: '',
},
maxlength: {
type: Number,
default: 30,
},
isAlert: {
type: Boolean,
default: false,
},
isCateAlert: {
type: Boolean,
default: false,
},
isLabel: {
type: Boolean,
default: true,
required: false,
},
disabled: {
type: Boolean,
default: false,
},
min: {
type: String,
default: '',
required: false,
},
isBtn: {
type: Boolean,
default: false,
required: false,
}
});
// Emits
const emits = defineEmits(['update:modelValue', 'focusout', 'update:alert']);
// Emits
const emits = defineEmits(['update:modelValue', 'focusout', 'update:alert']);
// `inputValue`
const inputValue = ref(props.modelValue);
// `inputValue`
const inputValue = ref(props.modelValue);
//
watch(inputValue, newValue => {
emits('update:modelValue', newValue);
});
//
watch(inputValue, (newValue) => {
emits('update:modelValue', newValue);
});
//
watch(
() => props.modelValue,
newValue => {
if (inputValue.value !== newValue) {
inputValue.value = newValue;
}
},
);
//
watch(() => props.modelValue, (newValue) => {
if (inputValue.value !== newValue) {
inputValue.value = newValue;
}
});
const handleInput = (event) => {
const newValue = event.target.value.slice(0, props.maxlength);
if (newValue.trim() !== '') {
emits('update:alert', false);
}
};
const handleInput = event => {
const newValue = event.target.value.slice(0, props.maxlength);
if (newValue.trim() !== '') {
emits('update:alert', false);
}
};
</script>

View File

@ -2,27 +2,39 @@
<div class="mb-2" :class="isRow ? 'row' : ''">
<label :for="name" class="col-md-2 col-form-label" :class="isLabel ? 'd-block' : 'd-none'">
{{ title }}
<span :class="isEssential ? 'link-danger' : 'none'">*</span>
<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 :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>
<div v-if="isColor && selected"
class="w-px-40 h-px-30"
:style="{backgroundColor: selected}">
<!-- 데이터가 있는 경우 원래 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>
<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>
@ -70,6 +82,11 @@ const props = defineProps({
default: true,
required: false,
},
isBtn: {
type: Boolean,
default: false,
required: false,
},
isCommon: {
type: Boolean,
default: false,
@ -121,10 +138,11 @@ watch(selectData, (newValue) => {
const selected = computed(() => {
//
const selectedItem = props.data.find(item =>
props.isCommon ? item.value === selectData.value : props.data.indexOf(item) === selectData.value
);
return selectedItem ? selectedItem.label : null;
});
</script>

View File

@ -5,7 +5,7 @@
<span :class="isEssential ? 'link-danger' : 'd-none'">*</span>
</label>
<div class="col-md-12">
<div v-if="useInputGroup" class="input-group mb-3">
<div v-if="useInputGroup" class="input-group mb-1">
<input
:id="name"
class="form-control"
@ -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,8 +29,10 @@
:value="computedValue"
:disabled="disabled"
:maxLength="maxlength"
:placeholder="title"
:placeholder="placeholder ? placeholder : title"
@blur="$emit('blur')"
@click="handleDateClick"
ref="inputElement"
/>
</div>
<div class="invalid-feedback" :class="isAlert ? 'd-block' : ''">{{ title }} 확인해주세요.</div>
@ -39,7 +41,7 @@
</template>
<script setup>
import { inject, computed } from 'vue';
import { inject, computed, ref } from 'vue';
const props = defineProps({
title: {
@ -87,9 +89,14 @@
default: false,
required: false,
},
placeholder: {
type: String,
default: ''
},
});
const emits = defineEmits(['update:data', 'update:alert', 'blur']);
const inputElement = ref(null);
// dayjs
const dayjs = inject('dayjs');
@ -118,4 +125,12 @@
emits('update:alert', false);
}
};
// date input
const handleDateClick = (event) => {
if (props.type === 'date' && inputElement.value) {
// : UI
inputElement.value.showPicker();
}
};
</script>

View File

@ -3,68 +3,71 @@
<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>
<p v-if="isProjectExpired" class="btn-icon btn-danger rounded-2"><i class='bx bx-power-off'></i></p>
<div v-if="!isProjectExpired">
<div v-if="!isProjectExpired" class="d-flex gap-1">
<EditBtn @click.stop="openEditModal" />
<DeleteBtn v-if="isProjectCreator" @click.stop="handleDelete" class="ms-1"/>
</div>
</div>
<!-- 날짜 -->
<div class="d-flex flex-column 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 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="d-flex flex-column flex-sm-row align-items-center pb-2">
<i class="bx bxs-user"></i>
<div class="ms-2">참여자</div>
<UserList :projctSeq="projctSeq" :showOnlyActive="isProjectExpired" class="ms-8 mb-0" />
<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="d-flex flex-column 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 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="d-flex flex-column flex-sm-row align-items-center pb-2">
<div class="d-flex" @click.stop="isPopoverVisible = !isPopoverVisible">
<i class="bx bxs-map cursor-pointer" ref="mapIconRef"></i>
<div class="ms-2">주소</div>
</div>
<div class="ms-12 position-relative">
{{ address }} {{ addressdtail }}
<!-- 팝오버 -->
<div v-if="isPopoverVisible" class="position-absolute map ">
<button type="button" class="btn-close popover-close" @click.stop="isPopoverVisible = !isPopoverVisible"></button>
<div class="card">
<div class="card-body p-1">
<KakaoMap
v-if="coordinates"
:lat="coordinates.lat"
:lng="coordinates.lng"
class="w-px-250 h-px-200"
@onLoadKakaoMap="onLoadKakaoMap"
>
<KakaoMapMarker
:lat="coordinates.lat"
:lng="coordinates.lng"
/>
</KakaoMap>
<div class="position-absolute top-50 translate-middle-y end-0 me-3 z-1 d-flex flex-column gap-1">
<button class="btn-secondary border-none" @click="zoomOut">+</button>
<button class="btn-secondary border-none" @click="zoomIn">-</button>
</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>
</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">log</button>
</div>
</div>
</div>
@ -72,7 +75,7 @@
<!-- 로그 모달 -->
<CenterModal :display="isModalOpen" @close="closeModal">
<template #title> Log </template>
<template #title> 등록·수정자 </template>
<template #body>
<div v-if="logData.length > 0">
<div
@ -95,7 +98,7 @@
<template #title> 프로젝트 수정 </template>
<template #body>
<FormInput
title="이름"
title="프로젝트명"
name="name"
:is-essential="true"
:is-alert="nameAlert"
@ -110,28 +113,51 @@
:is-essential="true"
:is-label="true"
:is-common="true"
:is-color="true"
:data="allColors"
:value="selectedProject.PROJCTCOL"
@update:data="selectedProject.PROJCTCOL = $event"
/>
<FormInput
title="시작일"
type="date"
name="startDay"
:is-essential="true"
:modelValue="selectedProject.PROJCTSTR"
@update:modelValue="selectedProject.PROJCTSTR = $event"
/>
<FormInput
title="종료일"
type="date"
name="endDay"
:modelValue="selectedProject.PROJCTEND"
@update:modelValue="selectedProject.PROJCTEND = $event"
/>
<div class="mb-2 row">
<label class="col-md-2 col-form-label">
참여자
</label>
<div class="col-md-10">
<UserList class="m-0"
ref="editUserListRef"
:projctSeq="projctSeq"
:showOnlyActive="false"
@user-list-update="handleEditUserListUpdate"
/>
</div>
</div>
<!-- 시작일 -->
<div @click="openStartDatePicker">
<FormInput
title="시작일"
type="date"
name="startDay"
:is-essential="true"
:is-alert="startDayAlert"
:modelValue="selectedProject.PROJCTSTR"
@update:modelValue="selectedProject.PROJCTSTR = $event"
ref="startDateInput"
/>
</div>
<!-- 종료일 -->
<div @click="openEndDatePicker">
<FormInput
title="종료일"
type="date"
name="endDay"
:min="selectedProject.PROJCTSTR"
:modelValue="selectedProject.PROJCTEND"
@update:modelValue="selectedProject.PROJCTEND = $event"
ref="endDateInput"
/>
</div>
<FormInput
title="설명"
name="description"
@ -164,7 +190,6 @@ import { defineProps, onMounted, ref, computed, watch } from 'vue';
import UserList from '@c/user/UserList.vue';
import CenterModal from '@c/modal/CenterModal.vue';
import $api from '@api';
import { KakaoMap, KakaoMapMarker } from 'vue3-kakao-maps';
import BackBtn from '@c/button/BackBtn.vue';
import BackButton from '@c/button/BackBtn.vue';
import SaveButton from '@c/button/SaveBtn.vue';
@ -173,13 +198,16 @@ import DeleteBtn from '../button/DeleteBtn.vue';
import FormInput from '@c/input/FormInput.vue';
import FormSelect from '@c/input/FormSelect.vue';
import ArrInput from '@c/input/ArrInput.vue';
import MapPopover from '@c/map/MapPopover.vue';
import { useToastStore } from '@s/toastStore';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import commonApi from '@/common/commonApi';
import commonApi, { refreshColorList } from '@/common/commonApi';
import { useProjectStore } from '@/stores/useProjectStore';
//
const toastStore = useToastStore();
const userStore = useUserInfoStore();
const projectStore = useProjectStore();
// Props
const props = defineProps({
@ -199,6 +227,7 @@ const props = defineProps({
description: {
type: String,
required: false,
default: "",
},
address: {
type: String,
@ -227,6 +256,14 @@ const props = defineProps({
projctCreatorId: {
type: Number,
required: false
},
resetUserSelection: {
type: Boolean,
default: false
},
searchParams: {
type: Object,
default: () => ({ text: '', year: null })
}
});
@ -237,22 +274,59 @@ const emit = defineEmits(['update']);
const isModalOpen = ref(false);
const logData = ref([]);
//
const isPopoverVisible = ref(false);
const map = ref();
const mapIconRef = ref(null);
const coordinates = ref(null);
const isMapVisible = ref(null);
//
const isEditModalOpen = ref(false);
const originalColor = ref('');
const nameAlert = ref(false);
const startDayAlert = ref(false);
const user = ref(null);
const editUserListRef = ref(null);
const userListRef = ref(null);
const selectedUsers = ref({
activeUsers: [],
disabledUsers: []
});
const startDateInput = ref(null);
const endDateInput = ref(null);
// DOM
let startInputElement = null;
let endInputElement = null;
const openStartDatePicker = () => {
if (startInputElement) {
startInputElement.showPicker();
}
};
const openEndDatePicker = () => {
if (endInputElement) {
endInputElement.showPicker();
}
};
const updatePopover = (visible) => {
isMapVisible.value = visible;
};
//
const handleEditUserListUpdate = (userLists) => {
selectedUsers.value = userLists;
};
const isProjectCreator = computed(() => {
return user.value?.id === props.projctCreatorId;
});
// ( )
const isProjectExpired = computed(() => {
if (!props.enddate) return false;
@ -286,12 +360,23 @@ const { colorList } = commonApi({
colorType: 'YNP',
});
// +
const allColors = computed(() => {
const existingColor = { value: selectedProject.value.PROJCTCOL, label: selectedProject.value.projctcolor };
return [existingColor, ...colorList.value];
// ( )
const existingColor = {
value: props.projctCol, //
label: props.projctColor //
};
//
const otherColors = colorList.value.filter(color => color.value !== existingColor.value);
//
return [existingColor, ...otherColors];
});
// ::
const updateAddress = addressData => {
selectedProject.value = {
@ -322,9 +407,23 @@ const closeModal = () => {
};
//
const openEditModal = () => {
const openEditModal = async () => {
selectedProject.value = {
PROJCTSEQ: props.projctSeq,
PROJCTNAM: props.title,
PROJCTSTR: props.strdate,
PROJCTEND: props.enddate,
PROJCTZIP: props.addressZip,
PROJCTARR: props.address,
PROJCTDTL: props.addressdtail,
PROJCTDES: props.description,
PROJCTCOL: props.projctCol,
projctcolor: props.projctColor,
};
isEditModalOpen.value = true;
originalColor.value = props.projctCol;
};
//
@ -343,11 +442,24 @@ const closeEditModal = () => {
};
isEditModalOpen.value = false;
// UserList resetSelection
if (editUserListRef.value) {
editUserListRef.value.resetSelection();
}
};
// selectedUsers
watch(() => selectedUsers.value.activeUsers, (newVal, oldVal) => {
}, { deep: true });
watch(() => selectedUsers.value.disabledUsers, (newVal, oldVal) => {
}, { deep: true });
//
const hasChanges = computed(() => {
return selectedProject.value.PROJCTNAM !== props.title ||
//
const basicChanges = selectedProject.value.PROJCTNAM !== props.title ||
selectedProject.value.PROJCTSTR !== props.strdate ||
selectedProject.value.PROJCTEND !== props.enddate ||
selectedProject.value.PROJCTZIP !== props.addressZip ||
@ -355,26 +467,37 @@ const hasChanges = computed(() => {
selectedProject.value.PROJCTDTL !== props.addressdtail ||
selectedProject.value.PROJCTDES !== props.description ||
selectedProject.value.PROJCTCOL !== props.projctCol;
});
//
watch(
() => selectedProject.value,
() => {
const start = new Date(selectedProject.value.PROJCTSTR);
const end = new Date(selectedProject.value.PROJCTEND);
if (end < start) {
selectedProject.value.PROJCTEND = selectedProject.value.PROJCTSTR;
//
const userChanges = editUserListRef.value?.hasUserChanges() || false;
return basicChanges || userChanges;
});
//
watch(
() => selectedProject.value.PROJCTSTR, // (strdate)
(newStartDate) => {
if (newStartDate && new Date(newStartDate) > new Date(selectedProject.value.PROJCTEND)) {
//
selectedProject.value.PROJCTEND = newStartDate;
}
},
{ deep: true, flush: 'post' }
}
);
//
const handleUpdate = () => {
nameAlert.value = selectedProject.value.PROJCTNAM.trim() === '';
// resetUserSelection
watch(() => props.resetUserSelection, () => {
if (editUserListRef.value) {
editUserListRef.value.resetSelection();
}
});
if (nameAlert.value) {
//
const handleUpdate = async () => {
nameAlert.value = selectedProject.value.PROJCTNAM.trim() === '';
startDayAlert.value = selectedProject.value.PROJCTSTR.trim() === '';
if (nameAlert.value || startDayAlert.value) {
return;
}
@ -383,7 +506,9 @@ const handleUpdate = () => {
return;
}
$api.patch('project/update', {
const disabledMemberSeqs = selectedUsers.value.disabledUsers.map(user => user.MEMBERSEQ);
const res = await $api.patch('project/update', {
projctSeq: selectedProject.value.PROJCTSEQ,
projctNam: selectedProject.value.PROJCTNAM,
projctCol: selectedProject.value.PROJCTCOL,
@ -395,68 +520,40 @@ const handleUpdate = () => {
projctDes: selectedProject.value.PROJCTDES || null,
projctUmb: user.value?.id,
originalColor: originalColor.value === selectedProject.value.PROJCTCOL ? null : originalColor.value,
}).then(res => {
if (res.status === 200) {
toastStore.onToast('수정이 완료 되었습니다.', 's');
closeEditModal();
//
emit('update');
}
disabledMembers: disabledMemberSeqs
});
};
//
const convertAddressToCoordinates = () => {
const geocoder = new window.kakao.maps.services.Geocoder();
if (res.status === 200) {
toastStore.onToast('수정이 완료 되었습니다.', 's');
geocoder.addressSearch(props.address, (result, status) => {
if (status === window.kakao.maps.services.Status.OK) {
coordinates.value = {
lat: parseFloat(result[0].y),
lng: parseFloat(result[0].x)
};
} else {
// ()
coordinates.value = {
lat: 37.2108651707078,
lng: 127.089445559923
};
}
});
};
//
await projectStore.getProjectList(props.searchParams.text, props.searchParams.year, 'false');
await projectStore.getMemberProjects();
await refreshColorList('YNP');
const onLoadKakaoMap = (mapRef) => {
map.value = mapRef;
};
await editUserListRef.value.fetchProjectParticipation();
await userListRef.value.fetchProjectParticipation();
//
const zoomIn = () => {
if (map.value) {
const level = map.value.getLevel();
map.value.setLevel(level + 1);
}
};
//
const zoomOut = () => {
if (map.value) {
const level = map.value.getLevel();
map.value.setLevel(level - 1);
closeEditModal();
emit('update', props.searchParams);
}
};
//
const handleDelete = () => {
$api.patch('project/delete', {
projctSeq: props.projctSeq,
projctCol: props.projctCol,
})
.then(res => {
if (res.status === 200) {
toastStore.onToast('삭제가 완료되었습니다.', 's');
location.reload()
}
})
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();
}
})
}
};
//
@ -465,8 +562,18 @@ onMounted(async () => {
await userStore.userInfo();
user.value = userStore.user;
convertAddressToCoordinates();
if (startDateInput.value) {
// FormInput input
startInputElement = startDateInput.value.$el.querySelector('input[type="date"]');
}
if (endDateInput.value) {
endInputElement = endDateInput.value.$el.querySelector('input[type="date"]');
}
});
</script>

View File

@ -0,0 +1,233 @@
<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-body">
<!-- 버튼 영역 -->
<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"
class="btn"
:class="selectedBoard === 'general' ? 'btn-primary' : 'btn-outline-primary'"
@click="changeBoard('general')"
>
자유
</button>
<button
type="button"
class="btn"
:class="selectedBoard === 'anonymous' ? 'btn-primary' : 'btn-outline-primary'"
@click="changeBoard('anonymous')"
>
익명
</button>
</div>
<!-- 게시글 미리보기 테이블 -->
<table class="table">
<thead>
<tr>
<!-- 익명게시판은 '닉네임', 나머지는 '작성자' -->
<th class="text-start">
<div class="ms-4">
{{ selectedBoard === 'anonymous' ? '닉네임' : '작성자' }}
</div>
</th>
<th class="text-start" style="width: 65%;">
<div class="ms-4">
제목
</div>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="post in currentList"
:key="post.id"
style="cursor: pointer;"
@click="goDetail(post.id, selectedBoard)"
>
<td class="text-start nickname-ellipsis small">
<div class="ms-4">
{{ selectedBoard === 'anonymous' ? post.nickname : post.author }}
</div>
</td>
<td class="text-start fs-6">
<div class="ms-4">
{{ truncateTitle(post.title) }}
<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 small"></i>
<i
v-if="post.hasAttachment.length > 0"
class="bi bi-paperclip ml-1 small"
></i>
<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>
</tr>
<tr v-if="currentList.length === 0">
<td colspan="3" class="text-center text-muted">게시물이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useRouter } from 'vue-router';
import axios from '@api';
import dayjs from 'dayjs';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
import 'bootstrap-icons/font/bootstrap-icons.css';
dayjs.extend(isToday);
dayjs.extend(isYesterday);
const router = useRouter();
// true
const isModalOpen = ref(true);
// : 'notices', 'general', 'anonymous'
const selectedBoard = ref('notices');
//
const noticeList = ref([]);
const freeList = ref([]);
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 [];
});
// : HH:mm, YYYY-MM-DD
const formatDate = dateString => {
const date = dayjs(dateString);
return date.isToday() ? date.format('HH:mm') : date.format('YYYY-MM-DD');
};
// 14 ...
const truncateTitle = title => {
return title.length > 7 ? title.slice(0, 7) + '...' : title;
};
// ( 5)
const fetchNoticePosts = async () => {
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) {
}
};
// board/general ( 10 5)
const fetchGeneralPosts = async () => {
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);
}
} catch (error) {
console.error(error);
}
};
//
const changeBoard = type => {
selectedBoard.value = type;
};
// ( )
const goDetail = (id, boardType) => {
router.push({ name: 'BoardDetail', params: { id }, query: { type: boardType } });
};
//
fetchNoticePosts();
fetchGeneralPosts();
</script>
<style scoped>
.table > :not(caption) > * > * {
padding: 0 !important;
}
</style>

View File

@ -0,0 +1,260 @@
<template>
<div class="event-modal position-fixed bg-white shadow rounded" :style="modalStyle">
<!-- 이벤트 선택 화면 -->
<div v-if="!selectedEventType" class="d-flex flex-wrap gap-2 p-2">
<div v-for="event in eventTypes" :key="event.code" class="event-icon-wrapper position-relative">
<img
:src="`${baseUrl}img/main-category-img/main-${event.code}.png`"
class="event-icon-select"
style="width: 25px; height: 25px; cursor: pointer"
@click="handleEventClick(event)"
/>
<!-- X 표시 수정 -->
<span v-if="isEventExists(event.type)" class="cancel-mark"> × </span>
</div>
</div>
<!-- 입력 화면 -->
<div v-else class="p-2" style="min-width: 200px">
<div class="d-flex justify-content-between align-items-center mb-2">
<small>{{ getEventTitle(selectedEventType) }}</small>
<button class="btn-close btn-close-sm" style="font-size: 8px" @click="resetForm"></button>
</div>
<div class="mb-2">
<input
type="text"
class="form-control form-control-sm py-1"
style="height: 25px; font-size: 12px"
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">
<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">
등록
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
position: {
type: Object,
required: true,
default: () => ({ x: 0, y: 0 }),
},
selectedDate: {
type: String,
required: true,
},
baseUrl: {
type: String,
required: true,
},
dateEvents: {
type: Array,
},
});
const emit = defineEmits(['select', 'delete', 'insert']);
//
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: '생일파티' },
{ type: 'dinner', code: '300204', title: '회식' },
{ type: 'teaTime', code: '300205', title: '티타임' },
{ type: 'workshop', code: '300206', title: '워크샵' },
];
const getEventTitle = type => {
return eventTypes.find(event => event.code === type)?.title || '';
};
const isEventExists = type => {
return props.dateEvents?.some(event => event.type === type);
};
const handleEventClick = event => {
if (isEventExists(event.type)) {
if (confirm('이벤트를 취소하시겠습니까?')) {
emit('delete', {
date: props.selectedDate,
code: event.code,
title: event.title,
});
}
} else {
selectedEventType.value = event.code;
noInputAlert.value = '';
noInputAlert2.value = '';
}
};
const handleSubmit = () => {
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 = '';
}
if (!eventTime.value) {
noInputAlert2.value = '시간을 입력해 주세요';
isValid = false;
} else {
noInputAlert2.value = '';
}
return isValid;
};
const handleChangeInput = () => {
noInputAlert.value = null;
};
const handleChangeInput2 = () => {
noInputAlert2.value = null;
};
const resetForm = () => {
selectedEventType.value = null;
eventPlace.value = '';
eventTime.value = '';
};
// computed
const modalStyle = computed(() => {
const modalWidth = 200; //
const modalHeight = 150; //
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
let x = props.position?.x || 0;
let y = props.position?.y || 0;
//
if (x + modalWidth > viewportWidth) {
x = viewportWidth - modalWidth - 10;
}
if (x < 0) {
x = 10;
}
if (y + modalHeight > viewportHeight) {
y = viewportHeight - modalHeight - 10;
}
if (y < 0) {
y = 10;
}
return {
left: `${x}px`,
top: `${y}px`,
zIndex: 1050,
maxWidth: '90vw', // 90%
maxHeight: '90vh', // 90%
};
});
</script>
<style scoped>
.event-icon-wrapper {
position: relative;
display: inline-block;
}
.event-icon-select {
transition: transform 0.2s;
}
.event-icon-select:hover {
transform: scale(1.1);
}
.cancel-mark {
position: absolute;
top: -8px;
right: -8px;
width: 16px;
height: 16px;
background-color: #dc3545;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
line-height: 1;
}
.event-modal {
min-width: 120px;
max-width: 300px;
overflow: auto;
}
/* 작은 화면에서의 스타일 */
@media (max-width: 576px) {
.event-modal {
min-width: 100px;
font-size: 0.9em;
}
}
</style>

View File

@ -0,0 +1,30 @@
<template>
<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 bxs-time-five pe-1"></i>{{ $common.dateFormatter(time, 'T') }}</span
>
</div>
</template>
<script>
export default {
props: {
place: {
type: String,
required: true,
},
time: {
type: String,
required: true,
},
},
};
</script>
<style>
@media (max-width: 767px) {
.font_767 {
font-size: 2rem;
}
}
</style>

View File

@ -0,0 +1,724 @@
<template>
<div class="card app-calendar-wrapper">
<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="`${profileImgUrl}profile/${user.profile}`"
alt="Profile Image"
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
ref="workTimeComponentRef"
:userId="user.id"
:checkedInProject="checkedInProject || {}"
:pendingProjectChange="pendingProjectChange"
@update:pendingProjectChange="pendingProjectChange = $event"
@leaveTimeUpdated="handleLeaveTimeUpdate"
/>
<MainEventList
:categoryList="categoryList"
:baseUrl="baseUrl"
:birthdayList="birthdayList"
:vacationList="vacationList"
:birthdayPartyList="birthdayPartyList"
:dinnerList="dinnerList"
:teaTimeList="teaTimeList"
:workShopList="workShopList"
@handle-click-vacation="handleClickVacation"
/>
</div>
</div>
<div class="col app-calendar-content">
<div class="card shadow-none border-0">
<div class="card-body">
<full-calendar
ref="fullCalendarRef"
:events="calendarEvents"
:options="calendarOptions"
defaultView="dayGridMonth"
class="flatpickr-calendar-only"
>
</full-calendar>
<input ref="calendarDatepicker" type="text" class="d-none" />
</div>
</div>
</div>
</div>
</div>
<EventModal
v-if="showModal"
:position="modalPosition"
:selected-date="selectedDate"
:base-url="baseUrl"
:date-events="currentDateEvents"
@select="handleEventSelect"
@delete="handleEventDelete"
@insert="handleEventInsert"
@close="handleCloseModal"
/>
</template>
<script setup>
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';
import CommuterBtn from '@c/commuters/CommuterBtn.vue';
import MainEventList from '@c/main/MainEventList.vue';
import EventModal from '@c/main/EventModal.vue';
import $api from '@api';
import 'flatpickr/dist/flatpickr.min.css';
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);
const modalPosition = ref({ x: 0, y: 0 });
const selectedDate = ref('');
//
const $common = inject('common');
const toastStore = useToastStore();
//
const pressTimer = ref(null);
const longPressDelay = 500; // 0.5
/************* category ***************/
//
const categoryList = ref([]);
const fetchCategoryList = async () => {
const { data } = await $api.get('main/category');
if (data) categoryList.value = [...data.data.filter(categoryInfo => categoryInfo.CMNCODODR != 0)];
};
/************* init ***************/
const monthBirthdayList = ref([]);
const monthVacationList = ref([]);
const monthBirthdayPartyList = ref([]);
const monthDinnerList = ref([]);
const monthTeaTimeList = ref([]);
const monthWorkShopList = ref([]);
const birthdayList = ref([]);
const vacationList = ref([]);
const birthdayPartyList = ref([]);
const dinnerList = ref([]);
const teaTimeList = ref([]);
const workShopList = ref([]);
const currentDateEvents = ref([]);
// , ,
const fetchEventList = async param => {
const { data } = await $api.get(`main/eventList?${param}`);
const res = data?.data;
//
const holidayEvents = calendarEvents.value.filter(event => event.classNames?.includes('holiday-event'));
calendarEvents.value = [...holidayEvents, ...dailyWeatherList.value];
//
if (res?.memberBirthdayList?.length) {
monthBirthdayList.value = [...res.memberBirthdayList];
res.memberBirthdayList.forEach(member => {
addEvent($common.dateFormatter(member.MEMBERBTH, 'YMD'), 'birthday', `${member.MEMBERNAM}`);
});
}
//
if (res?.memberVacationList?.length) {
monthVacationList.value = [...res.memberVacationList];
res.memberVacationList.forEach(member => {
addEvent($common.dateFormatter(member.LOCVACUDT, 'YMD'), 'vacation', `${member.MEMBERNAM}`);
});
}
//
monthBirthdayPartyList.value = [];
monthDinnerList.value = [];
monthTeaTimeList.value = [];
monthWorkShopList.value = [];
if (res?.eventList?.length) {
res.eventList.forEach(item => {
switch (item.CMNCODVAL) {
case 300203:
monthBirthdayPartyList.value = [...monthBirthdayPartyList.value, item];
addEvent($common.dateFormatter(item.LOCEVTTME, 'YMD'), 'birthdayParty', '생일파티');
break;
case 300204:
monthDinnerList.value = [...monthDinnerList.value, item];
addEvent($common.dateFormatter(item.LOCEVTTME, 'YMD'), 'dinner', '회식');
break;
case 300205:
monthTeaTimeList.value = [...monthTeaTimeList.value, item];
addEvent($common.dateFormatter(item.LOCEVTTME, 'YMD'), 'teaTime', '티타임');
break;
case 300206:
monthWorkShopList.value = [...monthWorkShopList.value, item];
addEvent($common.dateFormatter(item.LOCEVTTME, 'YMD'), 'workshop', '워크샵');
break;
}
});
}
};
//
const addEvent = (date, type, title) => {
//
if (type === 'birthday') {
const calendarApi = fullCalendarRef.value?.getApi();
if (calendarApi) {
const calendarDate = calendarApi.currentData.currentDate;
const { year } = $common.formatDateTime(new Date(calendarDate));
const birthDate = $common.dateFormatter(date, 'MD');
date = `${year}-${birthDate}`;
}
}
//
const existingEvent = calendarEvents.value.find(
event => $common.dateFormatter(event.start, 'MD') === $common.dateFormatter(date, 'MD') && event.type == type,
);
//
if (!existingEvent) {
calendarEvents.value.push({
start: date,
type: type,
title: title,
classNames: [`${type}-event`],
});
}
};
//
const useFilterEventList = (month, day) => {
//
if (monthBirthdayList.value) {
birthdayList.value = $common.filterTargetByDate(monthBirthdayList.value, 'MEMBERBTH', month, day);
}
//
if (monthVacationList.value) {
vacationList.value = $common.filterTargetByDate(monthVacationList.value, 'LOCVACUDT', month, day);
}
//
if (monthBirthdayPartyList.value) {
birthdayPartyList.value = $common.filterTargetByDate(monthBirthdayPartyList.value, 'LOCEVTTME', month, day);
}
//
if (monthDinnerList.value) {
dinnerList.value = $common.filterTargetByDate(monthDinnerList.value, 'LOCEVTTME', month, day);
}
//
if (monthTeaTimeList.value) {
teaTimeList.value = $common.filterTargetByDate(monthTeaTimeList.value, 'LOCEVTTME', month, day);
}
//
if (monthWorkShopList.value) {
workShopList.value = $common.filterTargetByDate(monthWorkShopList.value, 'LOCEVTTME', month, day);
}
};
//
const fetchData = async () => {
// FullCalendar API
const calendarApi = fullCalendarRef.value?.getApi();
if (!calendarApi) return;
const date = calendarApi.currentData.currentDate;
const { year, month } = $common.formatDateTime(new Date(date));
try {
//
const holidayEvents = await fetchHolidays(year, month);
calendarEvents.value = [...holidayEvents]; //
//
const param = new URLSearchParams();
param.append('year', year);
param.append('month', month);
param.append('day', '1'); //
await fetchEventList(param);
} catch (error) {
console.error('공휴일 정보 로딩 실패:', error);
}
};
// (, , )
const moveCalendar = async (value = 0) => {
const calendarApi = fullCalendarRef.value?.getApi();
if (value === 1) {
calendarApi.prev(); //
} else if (value === 2) {
calendarApi.next(); //
} else if (value === 3) {
calendarApi.today(); //
}
await fetchData();
};
//
const isSelectableDate = date => {
const checkDate = dayjs(date);
const isWeekend = checkDate.day() === 0 || checkDate.day() === 6; //
//
const isHoliday = calendarEvents.value.some(
event =>
event.classNames?.includes('holiday-event') && dayjs(event.start).format('YYYY-MM-DD') === checkDate.format('YYYY-MM-DD'),
);
return !isWeekend && !isHoliday;
};
//
const getCellClassNames = arg => {
const cellDate = dayjs(arg.date);
const classes = [];
// (, , )
if (!isSelectableDate(cellDate)) {
classes.push('fc-day-sat-sun');
} else {
//
classes.push('clickable');
}
return classes;
};
//
let todayEL = null;
const handleDateClick = info => {
if (isSelectableDate(info.date)) {
if ($common.isToday(info.date)) {
//
todayEL = info.dayEl;
todayEL.classList.remove('fc-day-today');
} else if (todayEL) {
//
todayEL.classList.add('fc-day-today');
todayEL = null;
}
}
const { month, day } = $common.formatDateTime(new Date(info.dateStr));
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;
//
const dateEvents = calendarEvents.value.filter(
event => $common.dateFormatter(event.start, 'YMD') === $common.dateFormatter(date, 'YMD'),
);
pressTimer.value = setTimeout(() => {
modalPosition.value = {
x: jsEvent.clientX,
y: jsEvent.clientY,
};
selectedDate.value = date;
currentDateEvents.value = dateEvents;
showModal.value = true;
pressTimer.value = null;
}, longPressDelay);
};
//
const handleMouseUp = () => {
if (pressTimer.value) {
clearTimeout(pressTimer.value);
pressTimer.value = null;
}
};
// api
const toggleEvent = async (date, code, title) => {
const { data } = await $api.post('main/toggleEvent', {
date: date,
code: code,
title: title,
});
if (data?.code === 200) toastStore.onToast(data.message);
const { year, month, day } = $common.formatDateTime(new Date(date));
const param = new URLSearchParams();
param.append('year', year);
param.append('month', month);
param.append('day', day);
await fetchEventList(param);
useFilterEventList(month, day);
};
// api
const insertEvent = async (date, code, title, place, time) => {
const dateTime = $common.dateFormatter(`${date} ${time}`);
const { data } = await $api.post('main/inserEvent', {
date: dateTime,
code: code,
title: title,
place: place,
});
if (data?.code === 200) toastStore.onToast(data.message);
const { year, month, day } = $common.formatDateTime(new Date(date));
const param = new URLSearchParams();
param.append('year', year);
param.append('month', month);
param.append('day', day);
await fetchEventList(param);
useFilterEventList(month, day);
};
//
const handleEventSelect = data => {
toggleEvent(data.date, data.code, data.title);
showModal.value = false;
};
//
const handleEventInsert = data => {
insertEvent(data.date, data.code, data.title, data.place, data.time);
showModal.value = false;
};
//
const handleEventDelete = data => {
toggleEvent(data.date, data.code, data.title);
showModal.value = false;
};
//
const handleCloseModal = () => {
showModal.value = false;
};
//
const handleEventContent = item => {
if (!item.event) return null;
//
if (item.event.classNames?.includes('holiday-event')) {
return {
html: `<div class="holiday-text" style="color: white;">${item.event.title}</div>`,
};
}
//
const eventType = item.event.extendedProps.type;
if (!eventType) return null;
let iconCode = '';
switch (eventType) {
case 'birthday':
iconCode = '300201';
break;
case 'vacation':
iconCode = '300202';
break;
case 'birthdayParty':
iconCode = '300203';
break;
case 'dinner':
iconCode = '300204';
break;
case 'teaTime':
iconCode = '300205';
break;
case 'workshop':
iconCode = '300206';
break;
default:
return null;
}
return {
html: `<img src="${baseUrl}img/main-category-img/main-${iconCode}.png" class="calendar-event-icon" style="width: 20px; height: 20px; margin: 2px;" />`,
};
};
//
const calendarOptions = reactive({
plugins: [dayGridPlugin, interactionPlugin],
initialView: 'dayGridMonth',
headerToolbar: {
left: 'today',
center: 'title',
right: 'prev,next',
},
locale: 'kr',
events: calendarEvents,
eventOrder: 'sortIdx',
contentHeight: 'auto',
eventContent: handleEventContent,
selectable: true,
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);
});
dateCell.addEventListener('mouseup', handleMouseUp);
dateCell.addEventListener('mouseleave', handleMouseUp);
},
dayCellClassNames: getCellClassNames,
unselectAuto: true,
droppable: false,
eventDisplay: 'block',
customButtons: {
prev: {
text: 'PREV',
click: () => moveCalendar(1),
},
today: {
text: 'TODAY',
click: () => moveCalendar(3),
},
next: {
text: 'NEXT',
click: () => moveCalendar(2),
},
},
});
//
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,
async () => {
await fetchData();
},
);
// 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;
//
const storedProject = projectStore.getSelectedProject();
if (storedProject) {
selectedProject.value = storedProject.PROJCTSEQ;
checkedInProject.value = storedProject;
}
//
const { year, month, day } = $common.getToday();
const param = new URLSearchParams();
param.append('year', year);
param.append('month', month);
param.append('day', day);
//
await fetchCategoryList();
await fetchEventList(param);
useFilterEventList(month, day);
//
window.addEventListener('wheel', handleWheelEvent);
window.addEventListener('click', colorToday);
datePickerStore.initDatePicker(fullCalendarRef, async (year, month, options) => {
//
await fetchData();
});
});
</script>
<style scoped>
::v-deep(.fc-h-event) {
background-color: transparent;
}
::v-deep(.event-modal) {
padding: 8px;
border: 1px solid #ddd;
}
::v-deep(.event-icon-select:hover) {
transform: scale(1.1);
transition: transform 0.2s;
}
/* 이벤트 모달 노출 시 텍스트 선택 방지 */
::v-deep(.fc-daygrid-day) {
user-select: none;
}
::v-deep(.fc-daygrid-day-events) {
display: flex;
flex-wrap: wrap;
justify-content: center;
/* align-content: flex-start;
align-items: center;
text-align: center !important; */
}
/* 공휴일만 가로로 넗게 나오게 */
::v-deep(.fc-daygrid-event-harness:has(.holiday-event)) {
width: 100% !important;
}
</style>

View File

@ -0,0 +1,137 @@
<template>
<div class="">
<template v-for="category in categoryList" :key="category.CMNCODVAL">
<div
v-if="
(category.CMNCODVAL === 300201 && birthdayList?.length) ||
(category.CMNCODVAL === 300202 && vacationList?.length) ||
(category.CMNCODVAL === 300203 && birthdayPartyList?.length) ||
(category.CMNCODVAL === 300204 && dinnerList?.length) ||
(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">
<div class="col-3 mx-0 px-0">
<div class="ratio ratio-1x1">
<img
:src="`${baseUrl}img/main-category-img/main-${category.CMNCODVAL}.png`"
:alt="`${category.CMNCODNAM}`"
@error="$event.target.src = '/img/icons/icon.png'"
/>
</div>
</div>
<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>
<template v-if="category.CMNCODVAL === 300203">
<MainEventBoard :place="birthdayPartyList[0].LOCEVTPLC" :time="birthdayPartyList[0].LOCEVTTME" />
</template>
<template v-if="category.CMNCODVAL === 300204">
<MainEventBoard :place="dinnerList[0].LOCEVTPLC" :time="dinnerList[0].LOCEVTTME" />
</template>
<template v-if="category.CMNCODVAL === 300205">
<MainEventBoard :place="teaTimeList[0].LOCEVTPLC" :time="teaTimeList[0].LOCEVTTME" />
</template>
<template v-if="category.CMNCODVAL === 300206">
<MainEventBoard :place="workShopList[0].LOCEVTPLC" :time="workShopList[0].LOCEVTTME" />
</template>
</div>
</div>
</div>
</template>
</div>
</template>
<script setup>
import { defineEmits } from 'vue';
import MainMemberProfile from '@c/main/MainMemberProfile.vue';
import MainEventBoard from '@c/main/MainEventBoard.vue';
const props = defineProps({
project: {
type: Object,
required: false,
},
categoryList: {
type: Array,
},
commuters: {
type: Array,
required: false,
},
baseUrl: {
type: String,
required: false,
},
user: {
type: Object,
required: false,
},
selectedProject: {
type: Number,
default: null,
},
checkedInProject: {
type: Object,
default: null,
},
birthdayList: {
type: Array,
},
vacationList: {
type: Array,
},
birthdayPartyList: {
type: Array,
},
dinnerList: {
type: Array,
},
teaTimeList: {
type: Array,
},
workShopList: {
type: Array,
},
});
defineEmits(['handleClickVacation']);
</script>
<style scoped>
.event-board {
width: 100%;
padding: 0.5rem;
}
.event-info {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.info-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.label {
font-weight: 500;
color: #666;
min-width: 3rem;
}
.content {
color: #333;
word-break: break-all;
}
</style>

View File

@ -0,0 +1,32 @@
<template>
<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">
<img
:src="`${profileImgUrl}profile/${member.MEMBERPRF}`"
:style="`border-color: ${member.usercolor} !important;`"
alt="User Profile"
class="rounded-circle border border-2 profile-img"
@error="$event.target.src = '/img/icons/icon.png'"
/>
</div>
</li>
</ul>
</div>
</template>
<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

@ -0,0 +1,244 @@
<template>
<div class="col-md-6 col-lg-4 col-xl-4 order-0 mb-6">
<div class="card h-100">
<!-- 더보기 버튼-->
<div class="d-flex">
<router-link
to="/voteboard"
class="btn btn-primary mr-1 pe-1 ps-1 ms-auto my-auto h-50"
>
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";
const getProfileImage = (profilePath) => {
return profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}` : defaultProfile;
};
const setDefaultImage = (event) => {
event.target.src = defaultProfile;
};
onMounted(() => {
getvoteList();
});
//
const getvoteList = () => {
$api.get('vote/getVoteList',{
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;
};
//
const goVoteList = () =>{
router.push({
path: '/voteboard',
query: {
voteset: '2' //
,myVote:'2' //
,id:id
}
});
}
//
const getDaysAgo = (dateString) => {
const inputDate = new Date(dateString); // Date
const today = new Date(); //
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)}일 전`;
};
</script>
<style scoped>
.user-avatar {
border: 3px solid;
}
</style>

View File

@ -0,0 +1,139 @@
<template>
<div class="col-md-6 col-lg-4 col-xl-4 order-0 mb-6">
<div class="card h-100">
<!-- 더보기 버튼 -->
<div class="d-flex ">
<router-link
to="/wordDict"
class="btn btn-primary mr-1 pe-1 ps-1 ms-auto my-auto h-50"
>
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>
<script setup>
import axios from '@api';
import { getCurrentInstance, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import commonApi from '@/common/commonApi';
const { appContext } = getCurrentInstance();
const $common = appContext.config.globalProperties.$common;
const router = useRouter();
onMounted(() => {
getwordList();
});
//
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;
};
const wordList = ref([]);
//
const getwordList = (searchKeyword='', indexKeyword='', category='') => {
axios.get('worddict/getWordList',{
params: {
searchKeyword : searchKeyword,
indexKeyword :indexKeyword,
category : category,
pageNum:6
}
})
.then(res => {
wordList.value = res.data.data.data;
});
};
// /
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;
};
//
const goWordList = (category, indexKeyword) => {
const firstChar = getFirstCharacter(indexKeyword[0]); //
router.push({
path: '/wordDict',
query: {
indexKeyword: firstChar,
category: category,
}
});
};
// 14 ...
const truncateTitle = title => {
return title.length > 25 ? title.slice(0, 25) + '...' : title;
};
</script>
<style scoped>
.user-avatar {
border: 3px solid;
}
</style>

View File

@ -0,0 +1,120 @@
<template>
<div class="row g-4 mt-2" v-if="memberList?.length">
<div>
<div class="card">
<div class="card-body p-3 row">
<div class="d-flex justify-content-between mb-2">
<h5 class="card-title fw-bold">사원 등록 관리</h5>
</div>
<div class="g-4 col-12 col-lg-6" v-for="member in memberList" :key="member.MEMBERSEQ">
<div class="card">
<div class="row card-body">
<div class="col-3 d-flex align-items-center">
<img
:src="`${imgURL}profile/${member.MEMBERPRF}`"
alt="Profile Image"
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="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 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>
<div class="ms-2">{{ $common.addHyphenToPhoneNumber(member.MEMBERTEL) }}</div>
</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.MEMBERCDT) }}</div>
</div>
</div>
<div class="col-2 d-flex align-items-center">
<div>
<div>
<label class="switch"
><input
type="checkbox"
: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)"
></button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue';
import { useToastStore } from '@s/toastStore';
import $api from '@api';
const memberList = ref([]);
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.map(member => ({
...member,
checked: false, // checked
}));
}
};
// api
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 member => {
if (!confirm('해당 사원 등록을 거절하시겠습니까?')) return;
const { data } = await $api.post('main/rejectMember', { memberSeq: member.MEMBERSEQ });
if (data?.data) {
toast.onToast(data.data, 's');
fetchRegisterMemberList();
}
};
onMounted(async () => {
await fetchRegisterMemberList();
});
</script>

View File

@ -0,0 +1,114 @@
<template>
<div class="position-relative">
<div @click="togglePopover">
<slot name="trigger">
<i class="bx bxs-map"></i>
</slot>
</div>
<div
v-if="isVisible"
class="position-absolute map z-3"
>
<button
type="button"
class="btn-close popover-close"
@click="togglePopover"
></button>
<div class="card">
<div class="card-body p-1">
<KakaoMap
v-if="coordinates"
:lat="coordinates.lat"
:lng="coordinates.lng"
class="w-px-250 h-px-200"
@onLoadKakaoMap="onLoadKakaoMap"
>
<KakaoMapMarker
:lat="coordinates.lat"
:lng="coordinates.lng"
/>
</KakaoMap>
<div class="position-absolute top-50 translate-middle-y end-0 me-3 z-1 d-flex flex-column gap-1">
<button class="btn-secondary border-none" @click="zoomOut">+</button>
<button class="btn-secondary border-none" @click="zoomIn">-</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { KakaoMap, KakaoMapMarker } from 'vue3-kakao-maps';
const props = defineProps({
address: {
type: String,
required: true
},
isVisible: {
type: Boolean,
required: false
}
});
const emit = defineEmits(['update-visible']);
const coordinates = ref(null);
const map = ref(null);
//
const convertAddressToCoordinates = () => {
return new Promise((resolve, reject) => {
if (!window.kakao || !window.kakao.maps) {
reject(new Error('Kakao Maps not loaded'));
return;
}
const geocoder = new window.kakao.maps.services.Geocoder();
geocoder.addressSearch(props.address, (result, status) => {
if (status === window.kakao.maps.services.Status.OK) {
resolve({
lat: parseFloat(result[0].y),
lng: parseFloat(result[0].x)
});
} else {
reject(new Error('Address conversion failed'));
}
});
});
};
const togglePopover = () => {
emit('update-visible', !props.isVisible);
};
const onLoadKakaoMap = (mapRef) => {
map.value = mapRef;
};
//
const zoomIn = () => {
if (map.value) {
const level = map.value.getLevel();
map.value.setLevel(level + 1);
}
};
//
const zoomOut = () => {
if (map.value) {
const level = map.value.getLevel();
map.value.setLevel(level - 1);
}
};
//
onMounted(async () => {
coordinates.value = await convertAddressToCoordinates();
});
</script>

View File

@ -1,7 +1,7 @@
<template>
<div
v-if="toastStore.toastModal"
:class="['bs-toast toast m-2 fade show', toastClass]"
:class="['bs-toast toast toast-placement-ex m-2 fade show', toastClass]"
role="alert"
aria-live="assertive"
aria-atomic="true"
@ -35,24 +35,3 @@ const toastClass = computed(() => {
return toastStore.toastType === 'e' ? 'bg-danger' : 'bg-success'; // red, blue
});
</script>
<style scoped>
.bs-toast {
position: fixed;
bottom: 20px; /* 화면 하단에 위치 */
right: 20px; /* 오른쪽에 위치 */
z-index: 2000; /* 충분히 높은 값으로 설정 */
max-width: 300px; /* 최대 너비 제한 */
opacity: 1;
transition: opacity 0.5s ease-in-out;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); /* 그림자 추가 */
}
.bg-primary {
background-color: #007bff !important; /* 성공 색상 */
}
.bg-danger {
background-color: #ff3e1d !important; /* 에러 색상 */
}
</style>

View File

@ -6,14 +6,14 @@
<button class="close-btn" @click="closeModal"></button>
</div>
<div class="vac-modal-body">
<p class="vac-modal-text">선물할 연차 개수를 선택세요.</p>
<p class="vac-modal-text">선물할 연차 개수를 선택세요.</p>
<div class="count-container">
<button @click="decreaseCount" :disabled="grantCount < 2" class="count-btn">-</button>
<span class="count-value">{{ grantCount }}</span>
<button @click="increaseCount" :disabled="grantCount >= availableQuota" class="count-btn">+</button>
</div>
<div class="custom-button-container">
<button class="custom-button" @click="saveVacationGrant" :disabled="grantCount === 0">
<button class="custom-button" @click="saveVacationGrant" :disabled="grantCount === 0 || isGiftButtonDisabled">
<i class="bx bx-gift"></i>
</button>
</div>
@ -23,14 +23,16 @@
</template>
<script setup>
import { ref, defineProps, defineEmits, watch, onMounted } from "vue";
import { ref, defineProps, defineEmits, watch, onMounted, computed } from "vue";
import axios from "@api";
import { useToastStore } from '@s/toastStore';
import { useUserInfoStore } from "@s/useUserInfoStore";
const userStore = useUserInfoStore();
const toastStore = useToastStore();
const props = defineProps({
isOpen: Boolean,
targetUser: Object,
remainingVacationData: Object,
});
const emit = defineEmits(["close", "updateVacation"]);
@ -39,6 +41,13 @@ const maxQuota = 2;
const sentCount = ref(0);
const availableQuota = ref(2);
const myUserId = computed(() => userStore.user.id);
const myRemainingQuota = computed(() => {
return props.remainingVacationData?.[myUserId.value] ?? 0;
});
const isGiftButtonDisabled = computed(() => {
return myRemainingQuota.value < 0;
});
//
const fetchSentVacationCount = async () => {
try {
@ -48,7 +57,6 @@ const fetchSentVacationCount = async () => {
availableQuota.value = Math.max(maxQuota - sentCount.value, 0);
grantCount.value = availableQuota.value;
} catch (error) {
console.error("🚨 연차 전송 기록 조회 실패:", error);
availableQuota.value = maxQuota;
grantCount.value = maxQuota;
}

View File

@ -1,7 +1,7 @@
<template>
<div v-if="isOpen" class="vac-modal-dialog" @click.self="closeModal">
<div class="vac-modal-content p-5 modal-scroll">
<h5 class="vac-modal-title">📅 연차 사용 내역</h5>
<h5 class="vac-modal-title">📅 연차 (누적 개수)</h5>
<button class="close-btn" @click="closeModal"></button>
<!-- 연차 목록 -->
<div class="vac-modal-body" v-if="mergedVacations.length > 0">
@ -11,9 +11,6 @@
:key="vac._expandIndex"
class="vacation-item"
>
<span v-if="vac.category === 'used'" class="fw-bold text-dark me-2">
{{ usedVacationIndexMap[vac._expandIndex] }})
</span>
<span :class="vac.category === 'used' ? 'fw-bold text-danger me-2' : 'fw-bold text-primary me-2'">
{{ vac.category === 'used' ? '-' : '+' }}
</span>
@ -22,12 +19,15 @@
>
{{ formatDate(vac.date) }}
</span>
<span v-if="vac.category === 'used'" class="fw-bold text-dark ms-1">
( {{ usedVacationIndexMap[vac._expandIndex] }} )
</span>
</li>
</ol>
</div>
<!-- 연차 데이터 없음 -->
<p v-else class="text-sm-center mt-10 text-gray">
🚫 사용한 연차가 없습니다.
<p v-else class="text-sm-center mt-10 text-gray vac-modal-title">
🚫 연차 내역이 없습니다.
</p>
</div>
</div>
@ -57,30 +57,34 @@ const emit = defineEmits(["close"]);
// (,)
let globalCounter = 0;
const usedVacations = computed(() => {
const result = [];
props.myVacations.forEach((v) => {
const count = v.used_quota || 1;
for (let i = 0; i < count; i++) {
result.push({
...v,
category: "used",
code: v.LOCVACTYP,
_expandIndex: globalCounter++,
});
}
const data = props.myVacations.flatMap((v) => {
const count = v.used_quota
return Array.from({ length: Math.ceil(count) }, (_, i) => ({
...v,
category: "used",
code: v.LOCVACTYP,
used_quota: count, //
_expandIndex: globalCounter++,
}));
});
return result;
return data;
});
//
const receivedVacations = computed(() =>
props.receivedVacations.map((v) => ({
...v,
category: "received",
}))
);
const receivedVacations = computed(() => {
const data = props.receivedVacations.flatMap((v) => {
const count = v.received_quota ?? 1;
return Array.from({ length: Math.ceil(count) }, (_, i) => ({
...v,
category: "received",
_expandIndex: globalCounter++,
}));
});
return data;
});
//
const sortedUsedVacationsAsc = computed(() => {
return [...usedVacations.value].sort((a, b) => {
@ -114,7 +118,7 @@ const mergedVacations = computed(() => {
//
const closeModal = () => {
emit("close");
emit("close");
};
</script>
<style scoped>

View File

@ -14,7 +14,7 @@
<div v-for="post in projectStore.projectList" :key="post.PROJCTSEQ">
<ProjectCard
:title="post.PROJCTNAM"
:description="post.PROJCTDES"
:description="post.PROJCTDES ?? ''"
:strdate="post.PROJCTSTR"
:enddate="post.PROJCTEND"
:address="post.PROJCTARR"
@ -24,7 +24,9 @@
:projctCol="post.PROJCTCOL"
:projctColor="post.projctcolor"
:projctCreatorId="post.PROJCTCMB"
@update="getProjectList"
:resetUserSelection="resetUserSelection"
:searchParams="{ text: searchText, year: selectedYear }"
@update="handleProjectUpdate"
/>
</div>
</div>
@ -35,7 +37,7 @@
<template #title> 프로젝트 등록 </template>
<template #body>
<FormInput
title="이름"
title="프로젝트명"
name="name"
:is-essential="true"
:is-alert="nameAlert"
@ -51,27 +53,48 @@
:is-label="true"
:is-common="true"
:is-color="true"
:value="color"
:data="colorList"
@update:data="color = $event"
:is-alert="colorAlert"
/>
<FormInput
title="시작 일"
name="startDay"
:type="'date'"
:is-essential="true"
:modelValue="startDay"
v-model="startDay"
/>
<FormInput
title="종료 일"
:type="'date'"
name="endDay"
:modelValue="endDay"
@update:modelValue="endDay = $event"
/>
<div class="mb-2 row">
<label class="col-md-2 col-form-label">
참여자
</label>
<div class="col-md-10">
<UserList
ref="userListRef"
@user-list-update="handleUserListUpdate"
class="m-0"
/>
</div>
</div>
<div @click="openStartDatePicker">
<FormInput
title="시작 일"
name="startDay"
:type="'date'"
:is-alert="startDayAlert"
:is-essential="true"
:modelValue="startDay"
v-model="startDay"
ref="startDateInput"
/>
</div>
<div @click="openEndDatePicker">
<FormInput
title="종료 일"
name="endDay"
:type="'date'"
:modelValue="endDay"
:min="startDay"
@update:modelValue="endDay = $event"
ref="endDateInput"
/>
</div>
<FormInput
title="설명"
name="description"
@ -99,7 +122,7 @@
</template>
<script setup>
import { computed, ref, watch, onMounted, inject } from 'vue';
import { computed, ref, watch, onMounted, inject, nextTick } from 'vue';
import SearchBar from '@c/search/SearchBar.vue';
import ProjectCard from '@c/list/ProjectCard.vue';
import CategoryBtn from '@/components/category/CategoryBtn.vue';
@ -108,7 +131,8 @@
import FormSelect from '@c/input/FormSelect.vue';
import FormInput from '@c/input/FormInput.vue';
import ArrInput from '@c/input/ArrInput.vue';
import commonApi from '@/common/commonApi';
import UserList from '@c/user/UserList.vue';
import commonApi, { refreshColorList } from '@/common/commonApi';
import { useToastStore } from '@s/toastStore';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
import { useProjectStore } from '@/stores/useProjectStore';
@ -125,6 +149,9 @@
const selectedCategory = ref(null);
const searchText = ref('');
const userListRef = ref(null);
const resetUserSelection = ref(false);
// dayjs
const dayjs = inject('dayjs');
@ -134,13 +161,37 @@
//
const isCreateModalOpen = ref(false);
const name = ref('');
const color = ref('');
const color = ref('0');
const startDay = ref(today);
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);
// DOM
let startInputElement = null;
let endInputElement = null;
const openStartDatePicker = () => {
if (startInputElement) {
startInputElement.showPicker();
}
};
const openEndDatePicker = () => {
if (endInputElement) {
endInputElement.showPicker();
}
};
const addressData = ref({
postcode: '',
@ -148,6 +199,17 @@
detailAddress: ''
});
//
const selectedUsers = ref({
activeUsers: [],
disabledUsers: []
});
// UserList
const handleUserListUpdate = (userLists) => {
selectedUsers.value = userLists;
};
// API
const { yearCategory, colorList } = commonApi({
loadColor: true,
@ -171,7 +233,7 @@
//
const getProjectList = async () => {
await projectStore.getProjectList(searchText.value, selectedYear.value);
await projectStore.getProjectList(searchText.value, selectedYear.value, 'false');
};
//
@ -180,17 +242,27 @@
});
//
const openCreateModal = () => {
const openCreateModal = async () => {
const updatedColors = await refreshColorList('YNP');
if (updatedColors && updatedColors.length > 0) {
color.value = updatedColors[0].value;
}
isCreateModalOpen.value = true;
};
const closeCreateModal = () => {
isCreateModalOpen.value = false;
resetUserSelection.value = !resetUserSelection.value;
};
const formReset = () => {
name.value = '';
color.value = '';
if (colorList.value && colorList.value.length > 0) {
color.value = colorList.value[0].value;
}
addressData.value = {
postcode: '',
address: '',
@ -201,37 +273,67 @@
description.value = '';
nameAlert.value = false;
addressAlert.value = false;
}
startDayAlert.value = false;
selectedUsers.value = {
activeUsers: [],
disabledUsers: []
};
if (userListRef.value) {
userListRef.value.resetSelection();
}
};
// ::
const handleAddressUpdate = (data) => {
addressData.value = data;
};
//
watch([startDay, endDay], () => {
if (startDay.value && endDay.value) {
const start = new Date(startDay.value);
const end = new Date(endDay.value);
if (end < start) {
endDay.value = startDay.value;
}
//
watch(startDay, (newStartDate) => {
if (new Date(newStartDate) > new Date(endDay.value)) {
endDay.value = '';
}
}, { flush: 'post' });
});
const handleProjectUpdate = async (params) => {
if (params) {
await projectStore.getProjectList(params.text, params.year, 'false');
} else {
await projectStore.getProjectList(searchText.value, selectedYear.value, 'false');
}
await projectStore.getMemberProjects();
//
const updatedColors = await refreshColorList('YNP');
// ()
if (updatedColors && updatedColors.length > 0) {
color.value = updatedColors[0].value;
}
};
//
const handleCreate = async () => {
nameAlert.value = name.value.trim() === '';
startDayAlert.value = startDay.value.trim() === '';
addressAlert.value = addressData.value.address.trim() === '';
if (nameAlert.value || addressAlert.value) {
if (!colorList.value || colorList.value.length === 0) {
colorAlert.value = true;
}
if (nameAlert.value || startDayAlert.value || addressAlert.value || colorAlert.value) {
return;
}
$api.post('project/insert', {
//
const disabledMemberSeqs = selectedUsers.value.disabledUsers.map(user => user.MEMBERSEQ);
const response = await $api.post('project/insert', {
projctNam: name.value,
projctCol: color.value,
projctStr: startDay.value,
@ -241,18 +343,36 @@
projctDtl: addressData.value.detailAddress,
projctZip: addressData.value.postcode,
projctCmb: user.value.id,
}).then(res => {
if (res.status === 200) {
toastStore.onToast('프로젝트가 등록되었습니다.', 's');
closeCreateModal();
getProjectList();
}
disabledMembers: disabledMemberSeqs
});
if (response.status === 200) {
toastStore.onToast('프로젝트가 등록되었습니다.', 's');
colorList.value = colorList.value.filter(c => c.value !== color.value);
formReset();
await getProjectList();
await projectStore.getMemberProjects();
closeCreateModal();
resetUserSelection.value = !resetUserSelection.value;
}
};
onMounted(async () => {
await getProjectList();
await userStore.userInfo();
user.value = userStore.user;
});
if (startDateInput.value) {
// FormInput input
startInputElement = startDateInput.value.$el.querySelector('input[type="date"]');
}
if (endDateInput.value) {
endInputElement = endDateInput.value.$el.querySelector('input[type="date"]');
}
});
</script>

View File

@ -1,31 +1,63 @@
<template>
<div class="input-group mb-3 d-flex">
<input type="text" class="form-control" placeholder="Search" @change="search" @input="preventLeadingSpace" />
<button type="button" class="btn btn-primary"><i class="bx bx-search bx-md"></i></button>
</div>
<form @submit.prevent="search">
<div class="input-group mb-3 d-flex">
<input type="text" class="form-control" placeholder="Search" v-model="searchQuery" @input="preventLeadingSpace" />
<button type="submit" class="btn btn-primary">
<i class="bx bx-search bx-md"></i>
</button>
</div>
</form>
</template>
<script setup>
import { ref, watch } from 'vue';
const props = defineProps({
maxlength: {
type: Number,
default: 30,
required: false,
},
initKeyword: {
type: String,
},
});
const emits = defineEmits(['update:data']);
const searchQuery = ref('');
const search = function (event) {
watch(
() => props.initKeyword,
(newVal, oldVal) => {
searchQuery.value = newVal;
},
);
//Type Number maxlength
if (event.target.value.length > props.maxlength) {
event.target.value = event.target.value.slice(0, props.maxlength);
// ( or )
const search = () => {
const trimmedQuery = searchQuery.value.trimStart();
if (trimmedQuery === '') {
emits('update:data', '');
return;
}
emits('update:data', event.target.value);
if (trimmedQuery.length < 2) {
alert('검색어는 최소 2글자 이상 입력해주세요.');
searchQuery.value = '';
return;
}
//
if (trimmedQuery.length > props.maxlength) {
searchQuery.value = trimmedQuery.slice(0, props.maxlength);
} else {
searchQuery.value = trimmedQuery;
}
emits('update:data', searchQuery.value);
};
const preventLeadingSpace = function (event) {
event.target.value = event.target.value.trimStart();
}
//
const preventLeadingSpace = () => {
searchQuery.value = searchQuery.value.trimStart();
};
</script>

View File

@ -23,7 +23,7 @@
<label class="form-check-label fw-bold" for="rememberCheck">&nbsp;자동로그인</label>
</div>
<RouterLink class="text-dark fw-bold" to="/register">등록신청</RouterLink>
<RouterLink class="text-dark fw-bold" to="/pw">비밀번호 찾기</RouterLink>
<RouterLink class="text-dark fw-bold" to="/pw">비밀번호 재설정</RouterLink>
</div>
</div>
</form>
@ -32,7 +32,7 @@
<script setup>
import $api from '@api';
import router from '@/router';
import { nextTick, ref } from 'vue';
import { ref } from 'vue';
import UserFormInput from '@c/input/UserFormInput.vue';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
@ -71,17 +71,16 @@
remember: remember.value,
}, { headers: { isLogin: true } })
.then(async res => {
if (res.status === 200) {
userStore.userInfo();
await nextTick();
router.push('/')
//
if (res.data.code > 10000) {
//
errorMessage.value = res.data.message;
return;
}
}).catch(error => {
if (error.response) {
error.config.isLoginRequest = true;
errorMessage.value = error.response.data.message;
console.clear();
}
});
//
await userStore.userInfo();
router.push('/');
})
};
</script>

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(public/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,7 +38,9 @@
@update:data="password = $event"
@update:alert="passwordAlert = $event"
:value="password"
@keypress="noSpace"
/>
<span v-if="passwordError" class="invalid-feedback d-block">{{ passwordError }}</span>
<UserFormInput
title="비밀번호 확인"
@ -47,20 +50,20 @@
:is-alert="passwordcheckAlert"
@update:data="passwordcheck = $event"
@update:alert="passwordcheckAlert = $event"
@blur="checkPw"
:value="passwordcheck"
@keypress="noSpace"
/>
<span v-if="passwordcheckError" class="invalid-feedback d-block">{{ passwordcheckError }}</span>
<FormSelect
title="비밀번호 힌트"
name="pwhint"
:is-essential="true"
:is-row="false"
:is-label="true"
:is-common="true"
:data="pwhintList"
@update:data="pwhint = $event"
title="비밀번호 힌트"
name="pwhint"
:is-essential="true"
:is-row="false"
:is-label="true"
:is-common="true"
:data="pwhintList"
@update:data="pwhint = $event"
/>
<UserFormInput
@ -82,6 +85,7 @@
@update:data="name = $event"
@update:alert="nameAlert = $event"
:value="name"
@keypress="noSpace"
class="me-2 w-50"
/>
@ -94,8 +98,8 @@
:is-common="true"
:is-color="true"
:data="colorList"
@update:data="color = $event"
@blur="checkColorDuplicate"
@update:data="handleColorUpdate"
:is-alert="colorAlert"
class="w-50"
/>
</div>
@ -136,6 +140,7 @@
@update:data="handleAddressUpdate"
@update:alert="addressAlert = $event"
:value="address"
:disabled="true"
/>
<UserFormInput
@ -143,11 +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>
@ -160,9 +166,9 @@
</template>
<script setup>
import { ref } from 'vue';
import { ref, watch } from 'vue';
import $api from '@api';
import commonApi from '@/common/commonApi'
import commonApi from '@/common/commonApi';
import UserFormInput from '@c/input/UserFormInput.vue';
import FormSelect from '@c/input/FormSelect.vue';
import ArrInput from '@c/input/ArrInput.vue';
@ -178,6 +184,7 @@
const id = ref('');
const idError = ref('');
const password = ref('');
const passwordError = ref('');
const passwordcheck = ref('');
const passwordcheckError = ref('');
const pwhintRes = ref('');
@ -185,22 +192,24 @@
const birth = ref('');
const address = ref('');
const detailAddress = ref('');
const postcode = ref(''); //
const postcode = ref(''); //
const phone = ref('');
const phoneError = ref('');
const color = ref(''); // color
const colorError = ref('');
const mbti = ref(''); // MBTI
const pwhint = ref(''); // pwhint
const mbti = ref(''); // MBTI
const pwhint = ref(''); // pwhint
const profilAlert = ref(false);
const idAlert = ref(false);
const idErrorAlert = ref(false);
const passwordAlert = ref(false);
const passwordErrorAlert = ref(false);
const passwordcheckAlert = ref(false);
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);
@ -209,6 +218,9 @@
const toastStore = useToastStore();
const noSpace = (e) => {
if (e.key === ' ') e.preventDefault();
};
//
const profileValid = (size, type) => {
@ -238,7 +250,7 @@
// ,
if (!profileValid(file.size, file.type)) {
e.target.value = '';
profileLabel.style.backgroundImage = 'url("public/img/avatars/default-Profile.jpg")';
profileLabel.style.backgroundImage = 'url("img/avatars/default-Profile.jpg")';
return false;
}
@ -248,8 +260,15 @@
profile.value = file;
};
//
//
const checkIdDuplicate = async () => {
//
if (id.value.length < 4) {
idError.value = '아이디는 4자리 이상이어야 합니다.';
idErrorAlert.value = true;
return;
}
const response = await $api.get(`/user/checkId?memberIds=${id.value}`);
if (!response.data.data) {
idErrorAlert.value = true;
@ -275,30 +294,19 @@
// , mbti,
const { colorList, mbtiList, pwhintList } = commonApi({
loadColor: true, colorType: 'YON',
loadColor: true,
colorType: 'YON',
loadMbti: true,
loadPwhint: true,
});
//
const handleAddressUpdate = (addressData) => {
const handleAddressUpdate = addressData => {
address.value = addressData.address;
detailAddress.value = addressData.detailAddress;
postcode.value = addressData.postcode; //
postcode.value = addressData.postcode; //
};
//
const checkPw = async () => {
if (password.value !== passwordcheck.value) {
passwordcheckError.value = '비밀번호가 일치하지 않습니다.';
passwordcheckErrorAlert.value = true;
} else {
passwordcheckError.value = '';
passwordcheckErrorAlert.value = false;
}
};
//
const checkColorDuplicate = async () => {
const response = await $api.get(`/user/checkColor?memberCol=${color.value}`);
@ -312,10 +320,63 @@
}
};
const handleColorUpdate = async newColor => {
color.value = newColor;
colorError.value = '';
colorErrorAlert.value = false;
await checkColorDuplicate();
}
const onlyNumber = (event) => {
//
if (!/^[0-9]$/.test(event.key)) {
event.preventDefault();
}
};
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 = '';
idErrorAlert.value = false;
} else if (newValue && newValue.length < 4) {
idError.value = '아이디는 4자리 이상이어야 합니다.';
idErrorAlert.value = true;
}
});
watch(password, (newValue) => {
if (newValue && newValue.length >= 4) {
passwordErrorAlert.value = false;
passwordError.value = '';
} else if (newValue && newValue.length < 4) {
passwordErrorAlert.value = true;
passwordError.value = '비밀번호는 4자리 이상이어야 합니다.';
}
});
//
watch([password, passwordcheck], ([newPassword, newPasswordCheck]) => {
if (newPassword && newPasswordCheck) {
if (newPassword !== newPasswordCheck) {
passwordcheckError.value = '비밀번호가 일치하지 않습니다.';
passwordcheckErrorAlert.value = true;
} else {
passwordcheckError.value = '';
passwordcheckErrorAlert.value = false;
}
}
});
//
const handleSubmit = async () => {
await checkColorDuplicate();
idAlert.value = id.value.trim() === '';
@ -327,6 +388,32 @@
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;
idError.value = '아이디는 4자리 이상이어야 합니다.';
}
//
if (password.value && password.value.length < 4) {
passwordErrorAlert.value = true;
passwordError.value = '비밀번호는 4자리 이상이어야 합니다.';
} else {
passwordError.value = '';
}
const phoneRegex = /^010\d{8}$/;
const isFormatValid = phoneRegex.test(phone.value);
if (!/^\d+$/.test(phone.value) || !isFormatValid) {
phoneAlert.value = true;
} else {
phoneAlert.value = false;
}
//
if (!profile.value) {
profilerr.value = '프로필 이미지를 선택해주세요.';
@ -336,20 +423,35 @@
profilAlert.value = false;
}
if (profilAlert.value || idAlert.value || idErrorAlert.value || passwordAlert.value || passwordcheckAlert.value ||
passwordcheckErrorAlert.value || pwhintResAlert.value || nameAlert.value || birthAlert.value ||
addressAlert.value || phoneAlert.value || phoneErrorAlert.value || colorErrorAlert.value) {
if (
profilAlert.value ||
idAlert.value ||
idErrorAlert.value ||
passwordAlert.value ||
passwordErrorAlert.value ||
passwordcheckAlert.value ||
passwordcheckErrorAlert.value ||
pwhintResAlert.value ||
nameAlert.value ||
birthAlert.value ||
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);
@ -357,7 +459,7 @@
formData.append('memberMbt', mbti.value);
formData.append('memberPrf', profile.value);
const response = await $api.post('/user/join', formData, { isFormData : true });
const response = await $api.post('/user/join', formData, { isFormData: true });
if (response.status === 200) {
toastStore.onToast('등록신청이 완료되었습니다. 관리자 승인 후 이용가능합니다.', 's');
@ -365,5 +467,3 @@
}
};
</script>
<style></style>

View File

@ -1,9 +1,9 @@
<template>
<ul class="list-unstyled users-list d-flex align-items-center gap-1">
<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,17 +23,21 @@
/>
</li>
</ul>
<span v-else >-</span>
</template>
<script setup>
import { onMounted, ref, nextTick, computed } from 'vue';
import { onMounted, ref, nextTick, computed, watch } from 'vue';
import { useUserStore } from '@s/userList';
import $api from '@api';
import { useToastStore } from "@s/toastStore";
const emit = defineEmits(['user-list-update']);
const userStore = useUserStore();
const userList = ref([]);
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const userProjectPeriods = ref([]);
const toastStore = useToastStore();
const props = defineProps({
projctSeq: {
@ -43,9 +47,39 @@ const props = defineProps({
showOnlyActive: {
type: Boolean,
default: false
},
role: {
type:String,
required:false
}
});
//
const originalDisabledUsers = ref([]);
const resetSelection = async () => {
//
if (props.projctSeq) {
//
userList.value = userList.value.map(user => ({
...user,
PROJCTYON: '1'
}));
//
await fetchProjectParticipation();
} else {
//
userList.value = userList.value.map(user => ({
...user,
disabled: false //
}));
}
emitUserListUpdate();
};
// computed
const sortedUserList = computed(() => {
return [...userList.value].sort((a, b) => {
@ -77,9 +111,37 @@ const fetchProjectParticipation = async () => {
...user,
PROJCTYON: projectMembers.find(pm => pm.MEMBERSEQ === user.MEMBERSEQ)?.PROJCTYON ?? '1'
}));
//
originalDisabledUsers.value = userList.value
.filter(user => user.PROJCTYON === '0')
.map(user => user.MEMBERSEQ);
emitUserListUpdate();
}
}
};
//
const fetchUserProjectPeriods = async () => {
if (props.projctSeq) {
try {
const response = await $api.get(`project/period/${props.projctSeq}`);
if (response.status === 200) {
userProjectPeriods.value = response.data.data;
}
} catch (error) {
console.error('프로젝트 참여 기간 조회 실패:', error);
}
}
};
// projctSeq
watch(() => props.projctSeq, async (newVal, oldVal) => {
if (newVal !== oldVal) {
await fetchProjectParticipation();
await fetchUserProjectPeriods();
}
}, { immediate: true });
//
onMounted(async () => {
@ -88,53 +150,57 @@ onMounted(async () => {
if (props.projctSeq) {
await fetchProjectParticipation();
await fetchUserProjectPeriods();
} else {
// projctSeq , emit
emitUserListUpdate();
}
nextTick(() => {
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach((tooltip) => {
new bootstrap.Tooltip(tooltip);
});
initTooltips();
});
});
//
const initTooltips = () => {
const tooltips = document.querySelectorAll('[data-bs-toggle="tooltip"]');
tooltips.forEach((tooltip) => {
if (tooltip._tooltip) {
tooltip._tooltip.dispose();
}
new bootstrap.Tooltip(tooltip);
});
};
//
const isUserDisabled = (user) => {
return props.projctSeq ? user.PROJCTYON === '0' : user.disabled;
};
// / DB
// showOnlyActive true toggleDisable
const toggleDisable = async (index) => {
if (props.showOnlyActive) return; // showOnlyActive true
// ( API )
const toggleDisable = (index) => {
if (props.showOnlyActive) return;
const user = displayedUserList.value[index];
if (user) {
const newParticipationStatus = props.projctSeq
? user.PROJCTYON === '1'
: !user.disabled;
if (props.projctSeq) {
const response = await $api.patch('project/updateYon', {
memberSeq: user.MEMBERSEQ,
projctSeq: props.projctSeq,
projctYon: newParticipationStatus ? '0' : '1'
});
if (response.status === 200) {
// userList
const originalIndex = userList.value.findIndex(u => u.MEMBERSEQ === user.MEMBERSEQ);
if (originalIndex !== -1) {
userList.value[originalIndex].PROJCTYON = newParticipationStatus ? '0' : '1';
}
}
} else {
// userList
const originalIndex = userList.value.findIndex(u => u.MEMBERSEQ === user.MEMBERSEQ);
if (originalIndex !== -1) {
userList.value[originalIndex].disabled = newParticipationStatus;
emitUserListUpdate();
}
//
if (props.role === 'vote') {
if (user.MEMBERSEQ === userStore.userInfo.id) {
toastStore.onToast('본인은 비활성화할 수 없습니다.', 'e');
return;
}
}
if (user) {
//
if (props.projctSeq) {
user.PROJCTYON = user.PROJCTYON === '1' ? '0' : '1';
} else {
user.disabled = !user.disabled;
}
emitUserListUpdate();
}
};
// emit
@ -144,7 +210,51 @@ const emitUserListUpdate = () => {
emit('user-list-update', { activeUsers, disabledUsers });
};
const getTooltipTitle = (user) => {
return user.MEMBERSEQ === userStore.userInfo.id ? '나' : user.MEMBERNAM;
//
const formatDate = (dateString) => {
if (!dateString) return '';
const date = new Date(dateString);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
//
const getTooltipTitle = (user) => {
const userName = user.MEMBERSEQ === userStore.userInfo.id ? '나' : user.MEMBERNAM;
if (props.projctSeq) {
const periodInfo = userProjectPeriods.value.find(p => p.MEMBERSEQ === user.MEMBERSEQ);
if (periodInfo) {
return `${formatDate(periodInfo.userStartDate)} ~ ${formatDate(periodInfo.userEndDate)}`;
}
}
return userName;
};
const hasUserChanges = () => {
if (!props.projctSeq) return false;
const currentDisabledUserIds = userList.value
.filter(user => user.PROJCTYON === '0')
.map(user => user.MEMBERSEQ);
//
if (currentDisabledUserIds.length !== originalDisabledUsers.value.length) {
return true;
}
// ID
return currentDisabledUserIds.some(id => !originalDisabledUsers.value.includes(id)) ||
originalDisabledUsers.value.some(id => !currentDisabledUserIds.includes(id));
};
// expose
defineExpose({
resetSelection,
fetchProjectParticipation,
hasUserChanges
});
</script>

View File

@ -1,28 +1,35 @@
<template>
<div class="card-body d-flex justify-content-center m-n5">
<ul class="list-unstyled profile-list">
<li
v-for="(user, index) in sortedUserList"
:key="index"
:class="{ disabled: user.disabled }"
@click="$emit('profileClick', user)"
data-bs-placement="top"
:aria-label="user.MEMBERSEQ"
>
<ul class="row gx-2 mb-0 list-inline ">
<li
v-for="(user, index) in sortedUserList"
:key="index"
class="col-4 mb-3"
:class="{ newRow: (index + 1) % 4 === 0 }"
@click="$emit('profileClick', user)"
data-bs-placement="top"
:aria-label="user.MEMBERSEQ"
>
<div class="ratio ratio-1x1 mb-0 profile-list position-relative">
<img
class="rounded-circle profile-img"
v-if="user.MEMBERSEQ === employeeId"
src="/img/icons/Crown.png"
alt="Crown"
class="start-50 translate-middle crown-icon"
/>
<img
class="rounded-circle object-fit-cover"
:src="getUserProfileImage(user.MEMBERPRF)"
alt="user"
:style="getDynamicStyle(user)"
@error="setDefaultImage"
@load="showImage"
/>
<span class="mt-2 text-sm-center d-block fs-6 remaining-vacation">
{{ remainingVacationData[user.MEMBERSEQ] || 0 }}
</span>
</li>
</ul>
</div>
</div>
<span class="mt-2 text-sm-center d-block fs-6 remaining-vacation">
{{ remainingVacationData[user.MEMBERSEQ] || 0 }}
</span>
</li>
</ul>
</template>
<script setup>
@ -69,10 +76,13 @@ nextTick(() => {
});
const sortedUserList = computed(() => {
if (!employeeId.value) return userList.value;
const myProfile = userList.value.find(user => user.MEMBERSEQ === employeeId.value);
const otherUsers = userList.value.filter(user => user.MEMBERSEQ !== employeeId.value);
return myProfile ? [myProfile, ...otherUsers] : userList.value;
if (!employeeId.value) return [];
// ( )
const myProfile = userList.value.find(user => user.MEMBERSEQ === employeeId.value);
const otherUsers = userList.value.filter(user => user.MEMBERSEQ !== employeeId.value);
return myProfile ? [myProfile, ...otherUsers] : otherUsers;
});
const getUserProfileImage = (profilePath) =>
@ -81,35 +91,18 @@ profilePath && profilePath.trim() ? `${baseUrl}upload/img/profile/${profilePath}
const setDefaultImage = (event) => (event.target.src = defaultProfile);
const showImage = (event) => (event.target.style.visibility = "visible");
//
const profileSize = computed(() => {
const totalUsers = userList.value.length;
if (windowWidth.value >= 1650) {
if (totalUsers <= 10) return "68px";
if (totalUsers <= 15) return "55px";
return "45px";
} else if (windowWidth.value >= 1300) {
if (totalUsers <= 10) return "45px";
if (totalUsers <= 15) return "40px";
return "30px";
} else if (windowWidth.value >= 1024) {
if (totalUsers <= 10) return "40px";
if (totalUsers <= 15) return "30px";
return "20px";
} else {
return "20px";
}
});
const getDynamicStyle = (user) => ({
width: profileSize.value,
height: profileSize.value,
borderWidth: "4px",
borderWidth: "3px",
borderColor: user.usercolor || "#ccc",
borderStyle: "solid",
});
</script>
<style scoped>
.crown-icon {
width: 90%;
height: 70%;
z-index: 0;
top: -7%
}
</style>

View File

@ -1,63 +1,65 @@
<template>
<div class="card mb-6">
<div class="card-body" v-if="!data.localVote.LOCVOTDEL" >
<div class="card mb-6" >
<div class="card-body " :class="{'disabled-class': data.localVote.LOCVOTDDT && (topVoters.length == 1 || data.localVote.LOCVOTRES || voteResult == 0)}" v-if="!data.localVote.LOCVOTDEL" >
<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"
:src="`${baseUrl}upload/img/profile/${data.localVote.MEMBERPRF}`"
:style="`border-color: ${data.localVote.usercolor} !important;`"
alt="user"
/>
<div class="w-100">
<div class="d-flex justify-content-between">
<div class="user-info">
<h6 class="mb-1">{{ data.localVote.MEMBERNAM }}</h6>
</div>
<div class="add-btn d-flex align-items-center">
<!-- 투표완료시 -->
<i v-if="data.yesVotetotal == '1'" class="bx bxs-check-circle link-success"></i>
<!-- 투표작성자만 수정/삭제/종료 가능 -->
<div v-if="userStore.user.id === data.localVote.LOCVOTREG">
<button
v-if="!data.localVote.LOCVOTDDT"
type="button"
class="bx btn btn-danger"
@click="endBtn(data.localVote.LOCVOTSEQ)"
>종료</button>
<DeleteBtn @click="voteDelete(data.localVote.LOCVOTSEQ)" />
<img
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'"
alt="user"
/>
<div class="w-100">
<div class="d-flex justify-content-between align-items-center">
<div class="user-info">
<h6 class="mb-1">{{ data.localVote.MEMBERNAM }}</h6>
<!-- 투표완료시 -->
</div>
<div class="add-btn d-flex align-items-center">
<!-- 투표작성자만 수정/삭제/종료 가능 -->
<div v-if="userStore.user.id === data.localVote.LOCVOTREG">
<button
v-if="!data.localVote.LOCVOTDDT"
type="button"
class="btn btn-label-danger btn-icon m-1"
@click="endBtn(data.localVote.LOCVOTSEQ)"
><i class="bx bx-power-off"></i>
</button>
<DeleteBtn v-if="!data.localVote.LOCVOTDDT" @click="voteDelete(data.localVote.LOCVOTSEQ)" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</h5>
<h5 class="mb-1">{{ data.localVote.LOCVOTTTL }}</h5>
<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>
<!-- 투표안했을시-->
<div v-if="data.localVote.LOCVOTDDT && data.voteResult.length == 0">
<div v-if="data.localVote.LOCVOTDDT && voteResult == 0">
<small class="text-primary text-uppercase">투표 결과없음 (😂아무도 투표하지 않았습니다)</small>
</div>
<div v-else>
<vote-card-check
v-if="data.yesVotetotal == 0"
@addContents="addContents"
@checkedNames="checkedNames"
:data="data.voteDetails"
:voteInfo="data.localVote"
:total="data.voteDetails.length "/>
<!-- 투표 결과 -->
<div v-if="data.localVote.LOCVOTDDT" class="mt-3">
<vote-result-list :data="data.voteResult" @randomList="randomList" :randomResultNum="data.localVote.LOCVOTRES"/>
</div>
<!-- 투표완/미완 인원 -->
<vote-user-list
:data="data.voteMembers"/>
<vote-card-check
v-if="yesVotetotal == 0 && !data.localVote.LOCVOTDDT"
@addContents="addContents"
@checkedNames="checkedNames"
:data="data.voteDetails"
:voteInfo="data.localVote"
:total="data.voteDetails.length "/>
<small v-if="yesVotetotal != 0 && !data.localVote.LOCVOTDDT">투표 완료 : 종료시 투표 결과가 나타납니다.</small>
<!-- 투표 결과 -->
<div v-if="data.localVote.LOCVOTDDT" class="mt-3">
<vote-result-list :data="topVoters" @randomList="randomList" :randomResultNum="data.localVote.LOCVOTRES" :locvotreg="data.localVote.LOCVOTREG" />
</div>
<!-- 투표완/미완 인원 -->
<vote-user-list
:data="data.voteMembers"/>
</div>
</div>
<div v-else class="card-body">
<div v-else class="card-body disabled-class">
<h5>{{ data.localVote.LOCVOTTTL }}</h5>
삭제된 투표입니다.
</div>
@ -65,7 +67,7 @@
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import EditBtn from '@c/button/EditBtn.vue';
import DeleteBtn from '@c/button/DeleteBtn.vue';
import voteUserList from '@c/voteboard/voteUserList.vue';
@ -84,22 +86,50 @@ const props = defineProps({
required: false,
},
});
const voteResult = computed(() => {
return props.data.voteDetails.reduce((sum, item) => sum + item.VOTE_COUNT, 0);
});
const yesVotetotal = computed(() => {
return props.data.voteDetails.reduce((sum, item) => sum + item.yesvote, 0);
});
// (1)
const topVoters = computed(() => {
// VOTE_COUNT
const maxVoteCount = Math.max(...props.data.voteDetails.map(item => item.VOTE_COUNT));
// VOTE_COUNT
return props.data.voteDetails.filter(item => item.VOTE_COUNT === maxVoteCount);
});
const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const userStore = useUserInfoStore();
const currentDate = new Date();
const voteEndDate = new Date(props.data.localVote.formatted_LOCVOTEDT.replace(' ', 'T'));
voteEndDate.setDate(voteEndDate.getDate() + 1);
const offset = new Date().getTimezoneOffset() * 60000
const today = new Date(Date.now() - offset);
const currentDate = today.toISOString().substring(0,16);
const voteEndDate = props.data.localVote.LOCVOTEDT.substring(0,16);
//
const isVoteEnded = computed(() => {
return currentDate > voteEndDate;
});
const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','randomList','voteDelete','updateVote']);
onMounted(() => {
if (isVoteEnded.value && !props.data.localVote.LOCVOTDDT) {
emit('voteEnded', { id: props.data.localVote.LOCVOTSEQ });
emit('voteEnded', { id: props.data.localVote.LOCVOTSEQ });
}
checkVoteCompletion();
});
//
watch(() => props.data.localVote.total_voted, () => {
checkVoteCompletion();
});
//
const checkVoteCompletion = () => {
if (props.data.localVote.total_votable === props.data.localVote.total_voted && props.data.localVote.LOCVOTDDT == null) {
emit('voteEnded', { id: props.data.localVote.LOCVOTSEQ });
}
};
const addContents = (itemList, voteId) =>{
emit('addContents',itemList,voteId)
}
@ -107,7 +137,6 @@ const checkedNames = (numList) =>{
emit('checkedNames',numList);
}
const endBtn = (voteid) =>{
voteEndDate.setTime(currentDate.getTime()); //
emit('endVoteId',voteid);
}
const voteDelete = (voteid) =>{

View File

@ -1,43 +1,50 @@
<template>
<div class="card-text">
<div class="demo-inline-spacing mt-4">
<!-- 투표리스트 -->
<div v-for="(item, index) in data"
:key="index">
<vote-card-check-list
:data="item"
:multiIs = voteInfo.LOCVOTMUL
:selectedValues="checkedNames"
@update:selectedValues="updateCheckedNames"
/>
<div v-if="voteInfo.LOCVOTADD ==='1' && index === data.length - 1" class="d-flex align-items-center">
<div class="d-flex flex-column gap-2">
<div v-for="(item, index) in itemList" :key="index" class="d-flex align-items-start">
<form-input
class="flex-grow-1 me-2"
:title="'항목 ' + (index + data.length + 1)"
:name="'content' + index"
:is-essential="false"
:is-alert="contentAlerts[index]"
v-model="item.content"
/>
<link-input v-model="item.url" />
<delete-btn @click="removeItem(index)" class="ms-2" />
<!-- 투표리스트 -->
<div v-for="(item, index) in data" :key="index" class="mt-3">
<vote-card-check-list
:data="item"
:multiIs = voteInfo.LOCVOTMUL
:selectedValues="checkedNames"
@update:selectedValues="updateCheckedNames"
/>
<div v-if="voteInfo.LOCVOTADD ==='1' && index === data.length - 1">
<div v-for="(item, index) in itemList" :key="index" class=" mt-2">
<form-input
:title="'항목 ' + (index + data.length + 1)"
:name="'content' + index"
:is-essential="false"
:is-alert="contentAlerts[index]"
v-model="item.content"
:is-btn="true"
@keyup="ValidHandler('content' + (index + 1))"
>
<template v-slot:append>
<delete-btn @click="removeItem(index)" />
</template>
</form-input>
<form-input
:title="'URL ' + (index + data.length + 1)"
:name="'url' + index"
v-model="item.url"
:is-essential="false"
class="mb-1"
:maxlength="maxLength"
/>
</div>
<div class="d-flex justify-content align-items-center mt-3">
<plus-btn @click="addItem" :disabled=" total >= 10" />
<button class="btn btn-primary btn-icon m-1" @click="addContentSave(item.LOCVOTSEQ ,index)" :disabled="isSaveDisabled">
<i class="bx bx-check"></i>
</button>
</div>
</div>
</div>
<div class="mb-4 d-flex justify-content">
<plus-btn @click="addItem" :disabled="total >= 10" class="mb-3" />
<button class="btn btn-primary btn-icon mb-3" @click="addContentSave(item.LOCVOTSEQ)" :disabled="isSaveDisabled">
<i class="bx bx-check"></i>
</button>
<div class="d-flex">
<save-btn class="mt-2 ms-auto" @click="selectVote"/>
</div>
</div>
</div>
</div>
</div>
</div>
<save-btn class="btn-sm mt-2" @click="selectVote"/>
</template>
<script setup>
@ -47,20 +54,20 @@ import SaveBtn from '@c/button/SaveBtn.vue'
import FormInput from '@c/input/FormInput.vue';
import voteCardCheckList from '@c/voteboard/voteCardCheckList.vue';
import { computed, ref } from 'vue';
import LinkInput from "@/components/voteboard/voteLinkInput.vue";
import { voteCommon } from '@s/voteCommon';
import DeleteBtn from "@c/button/DeleteBtn.vue";
import { useToastStore } from '@s/toastStore';
import router from '@/router';
const toastStore = useToastStore();
const contentAlerts = ref(false);
const contentAlerts = ref([false, false]);
const titleAlert = ref(false);
const title = ref('');
const rink = ref('');
const maxLength = ref(2000);
const { itemList, addItem, removeItem } = voteCommon(true);
const total = computed(() => props.total + itemList.value.length);
const isSaveDisabled = computed(() => {
return itemList.value.length === 0 || itemList.value.every(item => !item.content.trim());
return itemList.value.length === 0 || itemList.value.every(item => !item.content.trim() && !item.url.trim());
});
const props = defineProps({
data: {
@ -78,18 +85,43 @@ const props = defineProps({
});
const emit = defineEmits(['addContents','checkedNames']);
//
const addContentSave = (voteId) =>{
const addContentSave = (voteId,index) =>{
let valid = true;
const filteredItemList = itemList.value.filter(item => item.content && item.content.trim() !== '');
emit('addContents',filteredItemList,voteId);
itemList.value = [{ content: "", url: "" }];
itemList.value.forEach((item, index) => {
if (!item.content.trim() && item.url.trim()) {
contentAlerts.value[index] = true;
valid = false;
} else {
contentAlerts.value[index] = false;
}
});
if(valid){
emit('addContents',filteredItemList,voteId);
itemList.value = [{ content: "", url: "" }];
removeItem();
}
}
const ValidHandler = (field) => {
if (field.startsWith('content')) {
const index = parseInt(field.replace('content', '')) - 1;
if (!isNaN(index)) {
contentAlerts.value[index] = false;
}
}
};
const checkedNames = ref([]); //
const updateCheckedNames = (newValues) => {
checkedNames.value = newValues;
};
const selectVote = () =>{
emit('checkedNames',checkedNames.value);
if(checkedNames.value != ''){
emit('checkedNames',checkedNames.value);
}
}

View File

@ -1,6 +1,6 @@
<template>
<div class="list-group">
<label class="list-group-item">
<label class="list-group-item" style="cursor: pointer;">
<input
class="form-check-input me-1"
:name="data.LOCVOTSEQ"
@ -10,7 +10,12 @@
@change="handleChange"
>
{{ data.LOCVOTCON }}
<a v-if="data.LOCVOTLIK" :href="data.LOCVOTLIK.startsWith('http') ? data.LOCVOTLIK : 'http://' + data.LOCVOTLIK" target="_blank">
<a :style="{ maxWidth: (data.LOCVOTLIK.length * 1) + 'ch' }"
v-if="data.LOCVOTLIK"
:href="data.LOCVOTLIK.startsWith('http') ? data.LOCVOTLIK : 'http://' + data.LOCVOTLIK"
class="text-truncate"
target="_blank"
rel="noopener noreferrer">
{{ data.LOCVOTLIK }}
</a>
</label>
@ -40,7 +45,6 @@ const emit = defineEmits(["update:selectedValues"]);
const handleChange = (event) => {
const value = event.target.value;
let updatedValues = [];
//
if (props.multiIs === "1") {
updatedValues = event.target.checked
@ -52,8 +56,17 @@ const handleChange = (event) => {
}
emit("update:selectedValues", updatedValues);
};
const preventLinkMove = (event) =>{
event.preventDefault();
}
</script>
<style>
a {
display: block; /* 링크 텍스트에만 영역 적용 */
max-width: 500px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -1,18 +1,18 @@
<template>
<div>
<card
@addContents="addContents"
@checkedNames="checkedNames"
@endVoteId="endVoteId"
@voteEnded="voteEnded"
@voteDelete="voteDelete"
@randomList="randomList"
@updateVote="updateVote"
v-for="(item, index) in data"
:key="index"
:data="item"
/>
</div>
<div>
<card
@addContents="addContents"
@checkedNames="checkedNames"
@endVoteId="endVoteId"
@voteEnded="voteEnded"
@voteDelete="voteDelete"
@randomList="randomList"
@updateVote="updateVote"
v-for="(item, index) in data"
:key="index"
:data="item"
/>
</div>
</template>
<script setup>
@ -24,7 +24,7 @@ const props = defineProps({
},
});
const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','voteDelete']);
const emit = defineEmits(['addContents','checkedNames','endVoteId','voteEnded','voteDelete','randomList']);
const addContents = (itemList ,voteId) =>{
emit('addContents',itemList ,voteId);
}

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

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

View File

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

View File

@ -1,33 +1,34 @@
<template>
<div class="d-flex align-items-center">
<div class="mb-2 row">
<!-- 링크 아이콘 -->
<i class="bx bx-link-alt me-2" @click="togglePopover"></i>
<label for="name" class="col-md-2 col-form-label">
<div class="d-flex align-items-center">
<!-- 링크 아이콘 -->
<i class="bx bx-link-alt me-2" @click="togglePopover"></i>
<!-- 등록된 링크, 입력창이 보이지 않고 등록된 링크만 보일 -->
<span v-if="isLinkSaved && !isPopoverVisible" class="ms-2">
<a :href="formattedLink" class="d-inline-block text-truncate" target="_blank" rel="noopener noreferrer">
{{ link }}
</a>
</span>
</div>
</label>
<!-- 링크 입력창 (옆으로 나오게) -->
<div
v-if="isPopoverVisible"
class="popover-container d-flex align-items-center"
>
<input
v-model="link"
placeholder="URL을 입력해주세요"
class="form-control me-2"
style="min-width: 200px;"
/>
<save-btn class="btn-sm" @click="saveLink"/>
<div v-if="isPopoverVisible" class="col-md-10">
<div class="d-flex align-items-center">
<input
v-model="link"
placeholder="URL을 입력해주세요"
class="form-control me-2"
/>
<save-btn class="btn-icon" @click="saveLink"/>
</div>
</div>
<!-- 등록된 링크, 입력창이 보이지 않고 등록된 링크만 보일 -->
<span v-if="isLinkSaved && !isPopoverVisible" class="ms-2">
<a :href="formattedLink" target="_blank" rel="noopener noreferrer">{{ link }}</a>
</span>
</div>
</template>
<script setup>
import SaveBtn from '@c/button/SaveBtn.vue'
import { ref, computed } from "vue";
const props = defineProps({
@ -60,4 +61,10 @@ display: flex;
align-items: center;
gap: 8px; /* 아이콘과 입력창 간격 조정 */
}
a {
max-width: 500px; /* 원하는 너비로 조정 */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>

View File

@ -1,19 +1,19 @@
<template>
<div>
<ul class="timeline mb-0">
<li class="timeline-item ps-6 ">
<span class="timeline-indicator-advanced timeline-indicator-primary border-0 shadow-none">
<i class="icon-base bx bx-check-circle"></i>
</span>
<div class="timeline-event ps-1">
<div class="timeline-header">
<small class="text-primary text-uppercase">투표결과</small>
</div>
<h6 v-if="data" class="my-50">{{ data[0].LOCVOTCON }}</h6>
<h6 v-if="randomResultNum" class="my-50">{{randomResultNum }}</h6>
</div>
</li>
</ul>
<ul class="timeline mb-0">
<li class="timeline-item ps-6 ">
<span class="timeline-indicator-advanced timeline-indicator-primary border-0 shadow-none">
<i class="icon-base bx bx-check-circle"></i>
</span>
<div class="timeline-event ps-1">
<div class="timeline-header">
<small class="text-primary text-uppercase">투표결과</small>
</div>
<h6 v-if="data" class="my-50">{{ data[0].LOCVOTCON }}</h6>
<h6 v-if="randomResultNum" class="my-50">{{randomResultNum }}</h6>
</div>
</li>
</ul>
</div>
</template>

View File

@ -1,11 +1,11 @@
<template>
<div class="card mb-6 border border-2 border-primary rounded primary-shadow">
<div class="card-body">
<!-- 1위가 여러개일때 -->
<vote-result-random v-if="topVoters.length > 1" :data="topVoters" :randomResultNum="randomResultNum"
@randomList="randomList"/>
<!-- 1위가 하나일때-->
<vote-result-card v-if="topVoters.length == 1" :data="topVoters"/>
<!-- 1위가 여러개일때 -->
<vote-result-random v-if="topVoters.length > 1" :data="topVoters" :randomResultNum="randomResultNum" :locvotreg="locvotreg"
@randomList="randomList"/>
<!-- 1위가 하나일때-->
<vote-result-card v-if="topVoters.length == 1" :data="topVoters"/>
</div>
</div>
</template>
@ -24,6 +24,10 @@ const props = defineProps({
type: String,
required: false,
},
locvotreg: {
type: Number,
required: false,
},
});
// (1)
const topVoters = computed(() => {

View File

@ -1,24 +1,22 @@
<template>
<div v-if="!isRandom && !randomResultNum">
<h6><i class="icon-base bx bx-error icon-lg link-warning"></i> 1위가 중복 되었습니다</h6>
<!-- 중복된 1 리스트 -->
<vote-result-random-list
v-for="(item,index) in data"
:key="index"
:data="item"
:randomResultNum="randomResultNum"/>
<h6><i class="icon-base bx bx-error icon-lg link-warning"></i> 1위가 중복 되었습니다</h6>
<!-- 중복된 1 리스트 -->
<vote-result-random-list
v-for="(item,index) in data"
:key="index"
:data="item"
:randomResultNum="randomResultNum"/>
</div>
<div v-if="isRandom === false && randomResultNum">
<vote-result-card :randomResultNum="randomResultNum"/>
</div>
<button v-if="isRandom" class="btn btn-primary" type="button" disabled="">
<span class="spinner-grow me-1" role="status" aria-hidden="true"></span>
random..
</button>
<div class="d-grid w-100 mt-6">
<button v-if="!isRandom && !randomResultNum" @click="randomList" class="btn btn-primary"><i class='bx bx-sync'></i></button>
<div class="d-grid w-100 mt-6" v-if="userStore.user.id === locvotreg">
<button v-if="!isRandom && !randomResultNum" @click="randomList" class="btn btn-primary"><i class='bx bx-sync'></i></button>
</div>
</template>
@ -26,6 +24,7 @@
import voteResultRandomList from "@c/voteboard/voteResultRandomList.vue"
import voteResultCard from '@c/voteboard/voteResultCard.vue';
import { ref, watch } from "vue";
import { useUserInfoStore } from "@s/useUserInfoStore";
const emit = defineEmits(['randomList']);
const props = defineProps({
data: {
@ -36,7 +35,12 @@ const props = defineProps({
type: String,
required: false,
},
locvotreg: {
type: Number,
required: false,
},
});
const userStore = useUserInfoStore();
const isRandom = ref(false);
const randomList = () =>{
isRandom.value = true;

View File

@ -1,8 +1,8 @@
<template>
<div class="d-flex align-items-center">
<div class="d-flex align-items-start mt-3">
<!--투표한 사람 목록 -->
<div class="d-flex align-items-center gap-2">
<i class='bx bxs-user-check link-info'></i>
<div class="d-flex align-items-center gap-2 flex-wrap">
<i class='bx bxs-user-check link-info fa-2x'></i>
<vote-complete-user-list
v-for="(item, index) in voetedUsers"
:key="index"
@ -10,8 +10,8 @@
/>
</div>
<!-- 투표안한 사람 목록 -->
<div class="d-flex align-items-center gap-2 ms-auto">
<i class='bx bxs-user-x link-danger'></i>
<div class="d-flex align-items-center gap-2 ms-auto flex-wrap">
<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,56 +1,121 @@
<template>
<div>
<ul class="alphabet-list list-unstyled d-flex flex-wrap mb-0">
<li v-for="char in koreanChars" :key="char" class="mt-2 me-2">
<!-- <ul class="d-flex p-0 mb-0 flex-wrap">
<li class="d-flex">
<button
type="button"
class="alphabet-btn"
:class="{ active: selectedAl === 'all' }"
@click="selectAlphabet('all')"
> 전체 ({{ totalCount }})
</button>
</li>
</ul> -->
<div v-for="(group, groupIndex) in chunkedKoreanChars" :key="'ko-group-' + groupIndex">
<ul class="d-flex p-0 mb-0">
<li v-for="(char, index) in group" :key="char.CHARACTER_" class="d-flex">
<button
type="button"
class="btn"
:class="selectedAlphabet === char ? 'btn-primary' : 'btn-outline-primary'"
@click="selectAlphabet(char)"
class="alphabet-btn"
:class="{ active: selectedAl === char.CHARACTER_ }"
@click="selectAlphabet(char.CHARACTER_)"
>
{{ char }}
{{ char.CHARACTER_ }} ({{ char.COUNT }})
</button>
<span v-if="index !== group.length - 1" class="divider">|</span>
</li>
</ul>
<ul class="alphabet-list list-unstyled d-flex flex-wrap mb-0">
<li v-for="char in englishChars" :key="char" class="mt-2 me-2">
</div>
<div v-for="(group, groupIndex) in chunkedEnglishChars" :key="'en-group-' + groupIndex">
<ul class="d-flex p-0 mb-0">
<li v-for="(char, index) in group" :key="char.CHARACTER_" class="d-flex">
<button
type="button"
class="btn"
:class="selectedAlphabet === char ? 'btn-primary' : 'btn-outline-primary'"
@click="selectAlphabet(char)"
class="alphabet-btn"
:class="{ active: selectedAl === char.CHARACTER_ }"
@click="selectAlphabet(char.CHARACTER_)"
>
{{ char }}
{{ char.CHARACTER_ }} ({{ char.COUNT }})
</button>
<span v-if="index !== group.length - 1" class="divider">|</span>
</li>
</ul>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { ref, computed } from 'vue';
const koreanChars = ['ㄱ', 'ㄴ', 'ㄷ', 'ㄹ', 'ㅁ', 'ㅂ', 'ㅅ', 'ㅇ', 'ㅈ', 'ㅊ', 'ㅋ', 'ㅌ', 'ㅍ', 'ㅎ'];
const englishChars = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'];
const props = defineProps({
indexCategory: {
type: Array,
required: true,
},
selectedAl: {
type: String,
default: '',
required: false,
},
});
const selectedAlphabet = ref(props.selectedAl);
const totalCount = computed(() => {
return props.indexCategory.reduce((sum, item) => sum + item.COUNT, 0);
});
const chunkArray = (arr, size) => {
return arr.reduce((acc, _, i) => {
if (i % size === 0) acc.push(arr.slice(i, i + size));
return acc;
}, []);
};
const koreanChars = computed(() => {
return props.indexCategory.filter(char => /[-ㅎ가-]/.test(char.CHARACTER_));
});
const englishChars = computed(() => {
return props.indexCategory.filter(char => /^[a-zA-Z]$/.test(char.CHARACTER_));
});
const chunkedKoreanChars = computed(() => chunkArray(koreanChars.value, 5));
const chunkedEnglishChars = computed(() => chunkArray(englishChars.value, 5));
const selectedAlphabet = ref(null);
//emit
const emit = defineEmits();
const selectAlphabet = (alphabet) => {
selectedAlphabet.value = selectedAlphabet.value === alphabet ? null : alphabet;
emit('update:data',selectedAlphabet.value);
emit('update:data', selectedAlphabet.value);
};
</script>
<style scoped>
.btn {
min-width: 56px;
.alphabet-btn {
background: none;
border: none;
font-size: 13px;
font-weight: bold;
color: #6c757d;
cursor: pointer;
width: 70%;
height: 40px;
transition: color 0.3s ease, font-size 0.3s ease; /* Smooth transition for color */
}
@media (max-width: 768px) {
.alphabet-list {
overflow-x: scroll;
flex-wrap: nowrap !important;
}
.alphabet-btn:hover {
color: #0d6efd;
}
.alphabet-btn.active {
color: #0d6efd;
text-decoration: underline;
font-size: 13px; /* Keep font size fixed in active state */
}
.divider {
color: #bbb;
font-size: 14px;
font-weight: bold;
}
.flex-wrap {
flex-wrap: wrap;
}
</style>

View File

@ -1,5 +1,5 @@
<template>
<li class="mt-5 card p-5">
<li class="card p-4 mb-2">
<DictWrite
v-if="writeStore.isItemActive(item.WRDDICSEQ)"
@close="writeStore.closeAll();"
@ -9,63 +9,60 @@
:formValue="item.WRDDICCAT"
:titleValue="item.WRDDICTTL"
:contentValue="item.WRDDICCON"
:isDisabled="userStore.user.role !== 'ROLE_ADMIN'"
/>
:isDisabled="true"
:showEditBtn="true"
@toggleEdit="toggleEdit"
/>
<div v-else>
<input
v-if="userStore.user.role == 'ROLE_ADMIN'"
type="checkbox"
class="form-check-input admin-chk"
:name="item.WRDDICSEQ"
@change="toggleCheck($event)"
>
<div class="d-flex align-ite-center">
<div class="w-100 d-flex align-items-center">
<span class="btn btn-primary pe-none">{{ item.category }}</span>
<strong class="mx-2 w-75">{{ item.WRDDICTTL }}</strong>
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<span class="btn btn-primary pe-none m-2"
style="writing-mode: horizontal-tb;">{{ item.category }}</span>
{{ item.WRDDICTTL }}
</div>
<EditBtn
@click="toggleEdit"
:isToggleEnabled="true"
:isActive="writeStore.isItemActive(item.WRDDICSEQ)"
/>
</div>
<p class="mt-5 dict-content-wrap" v-html="$common.contentToHtml(item.WRDDICCON)"></p>
<div class="d-flex justify-content-between flex-wrap gap-2 mb-2">
<div class="d-flex flex-wrap align-items-center mb-50">
<div class="avatar avatar-sm me-2">
<img
class="rounded-circle user-avatar"
:src="getProfileImage(item.author.profileImage)"
alt="최초 작성자"
:style="{ borderColor: item.author.color}"
@error="setDefaultImage"
/>
<div class="d-flex align-items-start">
<!-- 최초 작성자 -->
<div class="d-flex flex-wrap align-items-center me-4">
<div class="avatar me-2">
<img
class="rounded-circle user-avatar object-fit-cover"
:src="getProfileImage(item.author.profileImage)"
alt="최초 작성자"
:style="{ borderColor: item.author.color }"
@error="setDefaultImage"
/>
</div>
<div>
<p class="mb-0 small fw-medium">{{ $common.dateFormatter(item.author.createdAt) }}</p>
<p class="mb-0 small fw-medium">{{ $common.dateFormatter(item.author.createdAt) }}</p>
</div>
</div>
</div>
<div
v-if="item.author.createdAt !== item.lastEditor.updatedAt"
class="d-flex justify-content-between flex-wrap gap-2 mb-2"
>
<div class="d-flex flex-wrap align-items-center mb-50">
<div class="avatar avatar-sm me-2">
<img
class="rounded-circle user-avatar"
:src="getProfileImage(item.lastEditor.profileImage)"
alt="최근 작성자"
:style="{ borderColor: item.lastEditor.color}"
@error="setDefaultImage"
/>
</div>
<div>
<p class="mb-0 small fw-medium">{{ $common.dateFormatter(item.lastEditor.updatedAt) }}</p>
</div>
</div>
</div>
</div>
<div class="edit-btn" v-if="userStore.user.role !== 'ROLE_ADMIN'">
<EditBtn ref="writeButton" @click="writeStore.toggleItem(item.WRDDICSEQ)" :isToggleEnabled="true"/>
<!-- 최근 작성자 (조건부) -->
<div
v-if="item.author.createdAt !== item.lastEditor.updatedAt"
class="d-flex flex-wrap align-items-center"
>
<div class="avatar me-2">
<img
class="rounded-circle user-avatar object-fit-cover"
:src="getProfileImage(item.lastEditor.profileImage)"
alt="최근 작성자"
:style="{ borderColor: item.lastEditor.color }"
@error="setDefaultImage"
/>
</div>
<div>
<p class="mb-0 small fw-medium">{{ $common.dateFormatter(item.lastEditor.updatedAt) }}</p>
</div>
</div>
</div>
</div>
</li>
</template>
@ -73,7 +70,7 @@
<script setup>
import axios from "@api";
import { useToastStore } from '@s/toastStore';
import { getCurrentInstance, ref } from 'vue';
import { getCurrentInstance, nextTick, ref } from 'vue';
import EditBtn from '@/components/button/EditBtn.vue';
import $api from '@api';
import DictWrite from './DictWrite.vue';
@ -124,12 +121,12 @@ const editWord = (data) => {
})
.then((res) => {
if (res.data.data === 1) {
toastStore.onToast('용어가 수정되었습니다.', 's');
toastStore.onToast('용어가 수정되었습니다.', 's');
writeStore.closeAll();
if (writeButton.value) {
writeButton.value.resetButton();
}
emit('refreshWordList');
emit('refreshWordList',data.category);
} else {
console.warn('⚠️ 서버 응답이 예상과 다릅니다:', res.data);
toastStore.onToast('용어 수정이 정상적으로 처리되지 않았습니다.', 'e');
@ -154,11 +151,10 @@ const setDefaultImage = (event) => {
event.target.src = defaultProfile;
};
//
const toggleCheck = (event) => {
emit('updateChecked', event.target.checked, props.item.WRDDICSEQ, event.target.name);
const toggleEdit = async () => {
writeStore.toggleItem(props.item.WRDDICSEQ);
};
</script>
<style scoped>
@ -168,7 +164,6 @@ const toggleCheck = (event) => {
.user-avatar {
border: 3px solid;
padding: 0.1px;
}
.edit-btn {
@ -183,4 +178,21 @@ const toggleCheck = (event) => {
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

@ -1,37 +1,42 @@
<template>
<div>
<div class="row">
<div class="col-10">
<FormSelect
name="cate"
title="카테고리 선택"
:data="dataList"
:is-common="true"
@update:data="selectCategory = $event"
@change="onChange"
:value="formValue"
:disabled="isDisabled"
/>
<div v-if="dataList.length > 0" >
<FormSelect
name="cate"
title="카테고리"
:data="dataList"
:is-common="true"
@update:data="selectCategory = $event"
@change="onChange"
:value="formValue"
:is-essential="false"
:is-btn="true"
>
<template v-slot:append>
<div>
<PlusBtn v-if="!showInput && !isDisabled" @click="toggleInput"/>
<EditBtn
v-if="showEditBtn"
@click="$emit('toggleEdit')"
:isToggleEnabled="true"
:isActive="writeStore.isItemActive(NumValue)"
/>
</div>
<div class="col-2 btn-margin" v-if="!isDisabled">
<PlusBtn @click="toggleInput" />
</div>
</div>
<div class="row" v-if="showInput">
<div class="col-10">
<FormInput
ref="categoryInputRef"
title="카테고리 입력"
name="카테고리"
@update:modelValue="addCategory = $event"
:is-cate-alert="addCategoryAlert"
@focusout="handleCategoryFocusout(addCategory)"
/>
</div>
</div>
</template>
</FormSelect>
</div>
<div v-if="dataList.length === 0 || showInput">
<FormInput
class="justify-content-end"
ref="categoryInputRef"
title="새 카테고리"
:isLabel="dataList.length === 0 ?true : false"
name="새 카테고리"
@update:modelValue="addCategory = $event"
:is-cate-alert="addCategoryAlert"
@focusout="handleCategoryFocusout(addCategory)"
/>
</div>
<div class="dict-w">
<FormInput
title="용어"
type="text"
@ -41,171 +46,197 @@
:modelValue="titleValue"
@update:modelValue="wordTitle = $event"
:disabled="isDisabled"
@keyup="ValidHandler('title')"
/>
</div>
<div>
<QEditor
@update:data="content = $event"
@update:imageUrls="imageUrls = $event"
:is-alert="wordContentAlert"
:initialData="contentValue"
/>
<div class="text-end mt-5">
<button class="btn btn-primary" @click="saveWord">
<i class="bx bx-check"></i>
</button>
<div>
<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" :disabled="titleValue ? !changed : false">
<i class="bx bx-check"></i>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps, computed, ref, defineEmits } from 'vue';
import { defineProps, computed, ref, defineEmits } from 'vue';
import QEditor from '@/components/editor/QEditor.vue';
import FormInput from '@/components/input/FormInput.vue';
import FormSelect from '@/components/input/FormSelect.vue';
import PlusBtn from '../button/PlusBtn.vue';
import QEditor from '@/components/editor/QEditor.vue';
import FormInput from '@/components/input/FormInput.vue';
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 props = defineProps({
dataList: {
type: Array,
default: () => []
},
NumValue : {
type: Number
},
formValue : {
type:[String, Number]
},
titleValue : {
type:String,
},contentValue : {
type:String,
},
isDisabled: {
type: Boolean,
default: false
},
showEditBtn: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['close', 'addCategory', 'addWord']);
const writeStore = useWriteVisibleStore();
//
const wordTitle = ref('');
const addCategory = ref('');
const content = ref('');
const imageUrls = ref([]);
const emit = defineEmits(['close','addCategory','addWord', 'toggleEdit']);
// Vaildation
const wordTitleAlert = ref(false);
const wordContentAlert = ref(false);
const addCategoryAlert = ref(false);
//
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 selectCategory = ref('');
//
const computedTitle = computed(() =>
wordTitle.value === '' ? props.titleValue : wordTitle.value
);
//
const computedTitle = computed(() => (wordTitle.value === '' ? props.titleValue : wordTitle.value));
//
const selectedCategory = computed(() =>
selectCategory.value === '' ? props.formValue : selectCategory.value
//
const selectedCategory = computed(() => (selectCategory.value === '' ? props.formValue : selectCategory.value));
);
// ref
const categoryInputRef = ref(null);
// ref
const categoryInputRef = ref(null);
const props = defineProps({
dataList: {
type: Array,
default: () => [],
},
NumValue: {
type: Number,
},
formValue: {
type: [String, Number],
},
titleValue: {
type: String,
},
contentValue: {
type: String,
},
isDisabled: {
type: Boolean,
default: false,
},
});
//
const showInput = ref(false);
//
const showInput = ref(false);
//
const toggleInput = () => {
showInput.value = !showInput.value;
//
const toggleInput = () => {
showInput.value = !showInput.value;
};
const onChange = (newValue) => {
selectCategory.value = newValue.target.value;
changed.value = true;
};
const ValidHandler = (field) => {
if(field == 'title'){
wordTitleAlert.value = false;
}
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"); //
};
//
const saveWord = () => {
let valid = true;
//validation
let computedTitleTrim;
if(computedTitle.value != undefined){
computedTitleTrim = computedTitle.value.trim()
}
//
if(computedTitleTrim == undefined || computedTitleTrim == ''){
wordTitleAlert.value = true;
valid = false;
} else {
wordTitleAlert.value = false;
}
//
let inserts = [];
if (inserts.length === 0 && content.value?.ops?.length > 0) {
inserts = content.value.ops.map(op =>
typeof op.insert === 'string' ? op.insert.trim() : op.insert
);
}
//
if(content.value == '' || inserts.join('') === ''){
wordContentAlert.value = true;
valid = false;
}else{
wordContentAlert.value = false;
}
const wordData = {
id: props.NumValue || null,
title: computedTitle.value.trim(),
category: selectedCategory.value,
content: content.value,
};
if(valid){
emit('addWord', wordData, addCategory.value.trim() === ''
? (isNaN(selectedCategory.value) ? selectedCategory.value : Number(selectedCategory.value))
: addCategory.value);
}
}
const onChange = newValue => {
selectCategory.value = newValue.target.value;
};
// focusout
const handleCategoryFocusout = (value) => {
if (!value || value.trim() === '') {
return;
}
const valueTrim = value.trim();
const existingCategory = props.dataList.find(item => item.label === valueTrim);
//
const saveWord = () => {
//validation
let computedTitleTrim;
if (existingCategory) {
addCategoryAlert.value = true;
if (computedTitle.value != undefined) {
computedTitleTrim = computedTitle.value.trim();
}
// focus
setTimeout(() => {
const inputElement = categoryInputRef.value?.$el?.querySelector('input');
if (inputElement) {
inputElement.focus();
}
}, 0);
} else {
addCategoryAlert.value = false;
}
};
//
if (computedTitleTrim == undefined || computedTitleTrim == '') {
wordTitleAlert.value = true;
return;
} else {
wordTitleAlert.value = false;
}
//
let inserts = [];
if (inserts.length === 0 && content.value?.ops?.length > 0) {
inserts = content.value.ops.map(op => (typeof op.insert === 'string' ? op.insert.trim() : op.insert));
}
//
if (content.value == '' || inserts.join('') === '') {
wordContentAlert.value = true;
return;
}
const wordData = {
id: props.NumValue || null,
title: computedTitle.value,
category: selectedCategory.value,
content: content.value,
};
emit('addWord', wordData, addCategory.value);
};
// focusout
const handleCategoryFocusout = value => {
const valueTrim = value.trim();
const existingCategory = props.dataList.find(item => item.label === valueTrim);
//
if (valueTrim == '') {
addCategoryAlert.value = true;
// focus
setTimeout(() => {
const inputElement = categoryInputRef.value?.$el?.querySelector('input');
if (inputElement) {
inputElement.focus();
}
}, 0);
} else if (existingCategory) {
addCategoryAlert.value = true;
// focus
setTimeout(() => {
const inputElement = categoryInputRef.value?.$el?.querySelector('input');
if (inputElement) {
inputElement.focus();
}
}, 0);
} else {
addCategoryAlert.value = false;
}
};
</script>
<style scoped>
.dict-w {
width: 83%;
}
@media (max-width: 768px) {
.btn-margin {
margin-top: 2.5rem;
}
}
<style>
.q-editor-container * {
max-width: 100% !important;
word-break: break-all !important;
box-sizing: border-box;
white-space: normal !important;
}
</style>

View File

@ -23,6 +23,8 @@
<!-- Drag Target Area To SlideIn Menu On Small Screens -->
<div class="drag-target"></div>
<ScrollTopButton />
</div>
</template>
<script setup>
@ -32,6 +34,7 @@
import TheChat from './TheChat.vue';
import { nextTick } from 'vue';
import { wait } from '@/common/utils';
import ScrollTopButton from '@c/button/ScrollTopButton.vue';
window.isDarkStyle = window.Helpers.isDarkStyle();
@ -48,26 +51,3 @@
loadScript('/js/main.js');
});
</script>
<style>
/* 중앙 콘텐츠 자동 조정 */
.layout-page {
flex-grow: 1;
min-width: 0; /* flexbox 내에서 올바른 크기 계산 */
margin-right: 20%; /* 채팅 사이드바의 너비만큼 밀리도록 설정 */
}
/* top bar 고정1 */
.layout-navbar-fixed .layout-navbar {
position: relative;
top: 0;
right: auto;
left: 0 !important;
width: 96% !important;
padding-top: 0 !important;
}
/* top bar 고정2 */
.layout-navbar-fixed .layout-wrapper:not(.layout-horizontal):not(.layout-without-menu) .layout-page {
padding-top: 0 !important;
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<!-- Chat Sidebar -->
<aside id="chat-sidebar" class="chat-sidebar">
<aside id="chat-sidebar" class="chat-sidebar bg-white position-fixed top-0 end-0 z-4 vh-100 menu border shadow">
</aside>
</template>
@ -25,19 +25,8 @@ const sendMessage = () => {
</script>
<style scoped>
/* 채팅 사이드바 고정 */
.chat-sidebar {
width: 20%;
height: 100vh;
position: fixed;
right: 0;
top: 0;
background: #fff;
border-left: 1px solid #ddd;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
z-index: 1000;
}
</style>

View File

@ -1,17 +1,7 @@
<template>
<footer class="content-footer footer bg-footer-theme">
<div class="container-xxl">
<div class="footer-container d-flex align-items-center justify-content-between py-4 flex-md-row flex-column">
<div class="text-body">
©2024
<!-- <script>
document.write(new Date().getFullYear())
</script> -->
, made with by
<a href="https://themeselection.com/" target="_blank" class="footer-link">ThemeSelection</a>
</div>
<div class="d-none d-lg-inline-block"></div>
</div>
</div>
</footer>
</template>

View File

@ -74,19 +74,32 @@
<div class="text-truncate">Commuters</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 v-if="userId === allowedUserId" class="menu-item" :class="$route.path.includes('/authorization') ? 'active' : ''">
<RouterLink class="menu-link" to="/authorization">
<i class="menu-icon icon-base bx bx-user-check"></i>
<div class="text-truncate">Authorization</div>
</RouterLink>
</li> -->
</li>
<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>
</ul>
</aside>
<!-- / Menu -->
</template>
<script setup>
import router from '@/router';
import { computed } from "vue";
import { useUserInfoStore } from '@s/useUserInfoStore';
import "bootstrap-icons/font/bootstrap-icons.css";
const userStore = useUserInfoStore();
const allowedUserId = 1; // ID (!!)
const userId = computed(() => userStore.user?.id ?? null);
</script>
<style lang="scss" scoped></style>

View File

@ -6,181 +6,87 @@
</a>
</div>
<div class="navbar-nav-right d-flex align-items-center" id="navbar-collapse">
<ul class="navbar-nav flex-row align-items-center ms-auto">
<!-- <button class="btn p-1" @click="switchToLightMode"><i class="bx bxs-sun link-warning"></i></button> -->
<!-- <button class="btn p-1" @click="switchToDarkMode"><i class="bx bxs-moon"></i></button> -->
<!-- 날씨 정보 영역 -->
<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" />
<div class="d-flex flex-column">
<span class="weather-desc">{{ weather.description }}</span>
<span class="weather-temp" v-if="weatherReady">
최저 {{ weather.tempMin }}° / 최고 {{ weather.tempMax }}°
</span>
</div>
</div>
</div>
<i class="bx bx-bell bx-md bx-log-out cursor-pointer p-1" @click="handleLogout"></i>
<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 cursor-pointer" id="name" v-model="selectedProject" @change="updateSelectedProject">
<!-- 내가 참여하고 있는 프로젝트 그룹 -->
<option v-for="item in myActiveProjects" :key="item.PROJCTSEQ" :value="item.PROJCTSEQ">
{{ item.PROJCTNAM }}
</option>
<!-- 내가 참여하지 않는 프로젝트 그룹 -->
<option v-if="otherActiveProjects.length > 0" disabled>-----------</option>
<option v-for="item in otherActiveProjects" :key="item.PROJCTSEQ" :value="item.PROJCTSEQ">
{{ item.PROJCTNAM }}
</option>
</select>
<i class="cursor-pointer p-2"></i>
<!-- Notification -->
<li class="nav-item dropdown-notifications navbar-dropdown dropdown me-3 me-xl-2 p-1">
<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 -->
<!-- User -->
<li class="nav-item navbar-dropdown dropdown-user dropdown">
<a class="nav-link dropdown-toggle hide-arrow p-0" href="javascript:void(0);" data-bs-toggle="dropdown">
<a class="nav-link dropdown-toggle hide-arrow p-1" href="javascript:void(0);" data-bs-toggle="dropdown">
<img
v-if="user"
:src="`${baseUrl}upload/img/profile/${user.profile}`"
alt="Profile Image"
class="w-px-40 h-px-40 rounded-circle"
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="pages-profile-user.html">
<i class="bx bx-user bx-md me-3"></i><span>My Profile</span>
<a class="dropdown-item" href="javascript:void(0)" @click="goToMyPage">
<i class="bx bx-user bx-md me-3"></i><span>My Page</span>
</a>
</li>
<li>
@ -188,82 +94,192 @@
<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>
<li>
<a class="dropdown-item" href="auth-login-cover.html" target="_blank">
<a class="dropdown-item" @click="handleLogout" target="_blank">
<i class="bx bx-power-off bx-md me-3"></i><span>Log Out</span>
</a>
</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 { useRouter } from 'vue-router';
import { useProjectStore } from '@/stores/useProjectStore';
import { useRouter, useRoute } from 'vue-router';
import { useThemeStore } from '@s/darkmode';
import { onMounted, ref } from 'vue';
import $api from '@api';
import { useWeatherStore } from '@/stores/useWeatherStore';
import { computed, onMounted, ref, watch } from 'vue';
import axios from '@api';
const user = ref(null);
//const baseUrl = $api.defaults.baseURL.replace(/api\/$/, '');
const baseUrl = import.meta.env.VITE_SERVER;
const authStore = useAuthStore();
const userStore = useUserInfoStore();
const projectStore = useProjectStore();
const router = useRouter();
const route = useRoute();
const weatherStore = useWeatherStore();
const { isDarkMode, switchToDarkMode, switchToLightMode } = useThemeStore();
const user = ref(null);
const selectedProject = ref(null);
const weather = ref({});
const dailyWeatherList = ref([]);
const notifications = ref([]);
const notificationCount = ref(0);
onMounted(async () => {
if (isDarkMode) {
switchToDarkMode();
} else {
switchToLightMode();
}
await userStore.userInfo();
user.value = userStore.user;
const weatherReady = computed(() => {
return (
weather.value &&
weather.value.tempMin !== null &&
weather.value.tempMax !== null &&
!!weather.value.description
);
});
//
const myActiveProjects = computed(() => {
return projectStore.activeMemberProjectList || [];
});
//
const otherActiveProjects = computed(() => {
if (!projectStore.activeProjectList) return [];
// ID
const myProjectIds = myActiveProjects.value.map(p => p.PROJCTSEQ);
//
return projectStore.activeProjectList.filter(p => !myProjectIds.includes(p.PROJCTSEQ));
});
//
const updateSelectedProject = () => {
if (!selectedProject.value) return;
//
let selected = projectStore.activeProjectList.find(project => project.PROJCTSEQ === selectedProject.value);
if (selected) {
projectStore.setSelectedProject(selected);
}
};
//
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();
// } else {
// switchToLightMode();
// }
await userStore.userInfo();
user.value = userStore.user;
await projectStore.loadAllProjectLists();
//
await projectStore.getMemberProjects();
if (myActiveProjects.value.length > 0) {
const firstProject = myActiveProjects.value[0];
selectedProject.value = firstProject.PROJCTSEQ;
projectStore.setSelectedProject(firstProject);
}
//
if (route.name !== 'login' && route.name !== undefined) {
//
await weatherStore.getWeatherInfoWithCache();
weather.value = weatherStore.weather; //
dailyWeatherList.value = weatherStore.dailyWeatherList; //
}
});
</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: 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

@ -1,43 +1,53 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from '@s/useAuthStore';
import { useUserInfoStore } from '@s/useUserInfoStore';
// 초기 렌더링 속도를 위해 지연 로딩 사용
const routes = [
{
path: '/',
name: "Home",
name: 'Home',
component: () => import('@v/MainView.vue'),
// meta: { requiresAuth: true }
meta: { requiresAuth: true },
},
{
path: '/board',
component: () => import('@v/board/TheBoard.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'BoardList',
component: () => import('@v/board/BoardList.vue')
component: () => import('@v/board/BoardList.vue'),
},
{
path: 'write',
component: () => import('@v/board/BoardWrite.vue')
name: 'BoardWrite',
component: () => import('@v/board/BoardWrite.vue'),
},
{
path: ':id',
name: 'BoardDetail',
component: () => import('@v/board/BoardView.vue')
component: () => import('@v/board/BoardView.vue'),
},
{
path: 'edit/:id',
name: 'BoardEdit',
component: () => import('@v/board/BoardEdit.vue')
}
]
component: () => import('@v/board/BoardEdit.vue'),
},
],
},
{
path: '/mypage',
name: 'MyPage',
component: () => import('@v/mypage/MyPage.vue'),
meta: { requiresAuth: true },
},
{
path: '/wordDict',
name: 'WordDict',
component: () => import('@v/wordDict/wordDict.vue'),
meta: { requiresAuth: true },
},
{
path: '/login',
@ -59,81 +69,118 @@ const routes = [
},
{
path: '/vacation',
name: 'Vacation',
component: () => import('@v/vacation/VacationManagement.vue'),
meta: { requiresAuth: true },
},
{
path: '/voteboard',
name: 'VoteBoard',
component: () => import('@v/voteboard/TheVoteBoard.vue'),
meta: { requiresAuth: true },
children: [
{
path: '',
component: () => import('@v/voteboard/voteBoardList.vue')
name: 'VoteBoardList',
component: () => import('@v/voteboard/voteBoardList.vue'),
},
{
path: 'write',
component: () => import('@v/voteboard/voteboardWrite.vue')
name: 'VoteboardWrite',
component: () => import('@v/voteboard/voteboardWrite.vue'),
},
]
],
},
{
path: '/projectlist',
name: 'Projectlist',
component: () => import('@v/projectlist/TheProjectList.vue'),
meta: { requiresAuth: true },
},
{
path: '/commuters',
name: 'Commuters',
component: () => import('@v/commuters/TheCommuters.vue'),
meta: { requiresAuth: true },
},
{
path: '/sample',
component: () => import('@c/calendar/SampleCalendar.vue'),
path: '/authorization',
name: 'Authorization',
component: () => import('@v/admin/TheAuthorization.vue'),
meta: { requiresAuth: true },
},
{
path: "/:anything(.*)",
component: () => import('@v/ErrorPage.vue'),
}
path: '/people',
name: 'people',
component: () => import('@v/people/PeopleList.vue'),
meta: { requiresAuth: true },
},
{
path: '/error/400',
name: 'Error400',
component: () => import('@v/error/Error400.vue'),
meta: { layout: 'NoLayout' },
},
{
path: '/error/500',
name: 'Error500',
component: () => import('@v/error/Error500.vue'),
meta: { layout: 'NoLayout' },
},
{
path: '/:anything(.*)',
name: 'Error404',
component: () => import('@v/error/Error404.vue'),
meta: { layout: 'NoLayout' },
},
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: routes,
})
});
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore();
await authStore.checkAuthStatus(); // 로그인 상태 확인
const allowedUserId = 1; // 특정 ID (변경필요!!)
const userStore = useUserInfoStore();
const userId = userStore.user?.id ?? null;
// 로그인이 필요한 페이지인데 로그인되지 않은 경우 → 로그인 페이지로 이동
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
// 로그인이 필요한 페이지인데 로그인되지 않은 경우 → 로그인 페이지로 이동
next({ name: 'Login' });
} else if (to.meta.requiresGuest && authStore.isAuthenticated) {
// 비로그인 사용자만 접근 가능한 페이지인데 로그인된 경우 → 홈으로 이동
next({ name: 'Home' });
} else {
next();
return next({ name: 'Login', query: { redirect: to.fullPath } });
}
// Authorization 페이지는 ID가 26이 아니면 접근 차단
if (to.path === '/authorization' && userId !== allowedUserId) {
return next();
}
// 비로그인 사용자만 접근 가능한 페이지인데 로그인된 경우 → 홈으로 이동
if (to.meta.requiresGuest && authStore.isAuthenticated) {
return next({ name: 'Home' });
}
// 모든 조건을 통과하면 정상적으로 이동
next();
});
import axios from 'axios';
router.beforeEach(async (to, from, next) => {
const authStore = useAuthStore()
axios.interceptors.response.use(
response => response,
error => {
const status = error.response?.status;
// 최초 앱 로드 시 인증 상태 체크
await authStore.checkAuthStatus()
if (status === 400) {
router.push({ name: 'Error400' });
} else if (status === 500) {
router.push({ name: 'Error500' });
}
// 현재 라우트에 인증이 필요한지 확인
const requiresAuth = to.meta.requiresAuth === true
return Promise.reject(error);
},
);
if (requiresAuth && !authStore.isAuthenticated) {
// 인증되지 않은 사용자를 로그인 페이지로 리다이렉트
// 원래 가려던 페이지를 쿼리 파라미터로 전달
next({
name: 'Login',
query: { redirect: to.fullPath }
})
} else {
next()
}
})
export default router
export default router;

View File

@ -0,0 +1,22 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
export const useLoadingStore = defineStore("loading", () => {
const loadingCount = ref(0); // 요청 개수를 추적
const startLoading = () => {
loadingCount.value++;
};
const stopLoading = () => {
if (loadingCount.value > 0) {
setTimeout(() => {
loadingCount.value--;
}, 200); // 약간의 지연을 추가하여 응답이 동시에 도착해도 안정적으로 감소
}
};
const isLoading = computed(() => loadingCount.value > 0); // 하나라도 요청이 있으면 로딩 활성화
return { isLoading, startLoading, stopLoading };
});

View File

@ -0,0 +1,45 @@
/*
작성자 : 박성용
작성일 : 2025-03-14
수정자 :
수정일 :
설명 : 게시글 수정 비밀번호 적재용.
*/
import { ref } from 'vue';
import { defineStore } from 'pinia';
export const useBoardAccessStore = defineStore(
'access',
() => {
const password = ref('');
// watch(password, newValue => {
// localStorage.setItem('tempPassword', JSON.stringify(newValue.value));
// });
if (localStorage.getItem('tempPassword')) {
// 저장된 값을 불러와 상태에 할당
const tempPassword = localStorage.getItem('tempPassword');
if (typeof tempPassword === 'string') password.value = JSON.parse(tempPassword);
}
function setBoardPassword(input) {
password.value = input;
if (typeof input === 'string') JSON.stringify(localStorage.setItem('tempPassword', input));
}
function $reset() {
password.value = '';
localStorage.removeItem('tempPassword');
}
return {
password,
setBoardPassword,
$reset,
};
},
{
persist: true,
},
);

View File

@ -0,0 +1,95 @@
/*
작성자 : 박지윤
작성일 : 2025-03-25
수정자 :
수정일 :
설명 : 달력 데이트 피커
*/
import { ref } from 'vue';
import flatpickr from 'flatpickr';
import monthSelectPlugin from 'flatpickr/dist/plugins/monthSelect/index';
import 'flatpickr/dist/flatpickr.min.css';
import 'flatpickr/dist/plugins/monthSelect/style.css';
export function useDatePicker() {
let fpInstance = null;
const calendarDatepicker = ref(null);
const initDatePicker = (fullCalendarRef, onDateChange, options = {}) => {
// input 요소 동적 생성
const datePickerInput = document.createElement('input');
datePickerInput.type = 'text';
datePickerInput.style.display = 'none';
document.body.appendChild(datePickerInput);
calendarDatepicker.value = datePickerInput;
// Flatpickr 초기화
fpInstance = flatpickr(calendarDatepicker.value, {
dateFormat: "Y-m",
plugins: [
new monthSelectPlugin({
shorthand: true,
dateFormat: "Y-m",
altFormat: "F Y"
})
],
onOpen: function() {
document.querySelector('.flatpickr-input').style.visibility = 'hidden';
},
onChange: function(selectedDatesArr, dateStr) {
// 선택한 달의 첫날로 달력을 이동
if (fullCalendarRef.value) {
fullCalendarRef.value.getApi().gotoDate(dateStr + "-01");
}
const [year, month] = dateStr.split("-");
// onDateChange가 함수인 경우에만 호출
if (typeof onDateChange === 'function') {
onDateChange(parseInt(year, 10), month, options);
}
},
onClose: function() {
if (calendarDatepicker.value) {
calendarDatepicker.value.style.display = "none";
}
},
...options
});
// FullCalendar 년월월(.fc-toolbar-title) 클릭 시 데이트피커 열기
const titleEl = document.querySelector('.fc-toolbar-title');
if (titleEl) {
titleEl.style.cursor = 'pointer';
titleEl.addEventListener('click', () => {
const rect = titleEl.getBoundingClientRect();
const dpEl = calendarDatepicker.value;
dpEl.style.display = 'block';
dpEl.style.position = 'fixed';
dpEl.style.top = `${rect.bottom + window.scrollY}px`;
dpEl.style.left = `${rect.left + window.scrollX}px`;
dpEl.style.transform = 'translate(-50%, -50%)';
dpEl.style.zIndex = '9999';
dpEl.style.border = 'none';
dpEl.style.outline = 'none';
dpEl.style.backgroundColor = 'transparent';
fpInstance.open();
});
}
};
const closeDatePicker = () => {
if (fpInstance) {
fpInstance.close();
}
};
return {
initDatePicker,
closeDatePicker,
calendarDatepicker
};
}

View File

@ -6,22 +6,122 @@
설명 : 프로젝트 목록
*/
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { ref, watch } from 'vue';
import $api from '@api';
import { useUserInfoStore } from '@/stores/useUserInfoStore';
export const useProjectStore = defineStore('project', () => {
const projectList = ref([]);
const projectList = ref([]); // 모든 프로젝트 (종료된 프로젝트 포함)
const activeProjectList = ref([]); // 진행 중인 프로젝트만 (종료된 프로젝트 제외)
const memberProjectList = ref([]); // 사용자가 속한 프로젝트
const activeMemberProjectList = ref([]); // 사용자가 속한 진행 중인 프로젝트
const selectedProject = ref(null);
const userStore = useUserInfoStore();
const getProjectList = async (searchText = '', selectedYear = '') => {
// 전체 프로젝트 가져오기 (종료된 프로젝트 포함 여부에 따라 다른 배열에 저장)
const getProjectList = async (searchText = '', selectedYear = '', excludeEnded = 'false') => {
if (!userStore.user) return;
const res = await $api.get('project/select', {
params: {
searchKeyword: searchText || '',
category: selectedYear || '',
excludeEnded: excludeEnded
},
});
projectList.value = res.data.data.projectList;
if (excludeEnded === 'true') {
// 종료되지 않은 프로젝트만 저장
activeProjectList.value = res.data.data.projectList;
} else {
// 모든 프로젝트 저장 (종료된 프로젝트 포함)
projectList.value = res.data.data.projectList;
}
};
// 모든 프로젝트 목록 로드 (종료 여부 구분하여 모두 로드)
const loadAllProjectLists = async (searchText = '', selectedYear = '') => {
// 진행 중인 프로젝트 로드
await getProjectList(searchText, selectedYear, 'true');
// 모든 프로젝트 로드 (종료된 프로젝트 포함)
await getProjectList(searchText, selectedYear, 'false');
};
return { projectList, getProjectList };
// 사용자가 속한 프로젝트 목록 가져오기
const getMemberProjects = async () => {
if (!userStore.user) return; // 로그인한 사용자 확인
const res = await $api.get(`project/${userStore.user.id}`);
const allMemberProjects = res.data.data;
memberProjectList.value = allMemberProjects;
// 사용자가 속한 프로젝트 중 진행 중인 프로젝트만 필터링
activeMemberProjectList.value = allMemberProjects.filter(project =>
!project.ENDYN || project.ENDYN === 'N'
);
if (activeMemberProjectList.value.length > 0 && !selectedProject.value) {
setSelectedProject(activeMemberProjectList.value[0]);
}
};
const setSelectedProject = (project) => {
selectedProject.value = project ? { ...project } : null;
if (project) {
localStorage.setItem('selectedProject', JSON.stringify(project));
} else {
localStorage.removeItem('selectedProject');
}
};
const getSelectedProject = () => {
if (!selectedProject.value) {
const storedProject = localStorage.getItem('selectedProject');
if (storedProject) {
selectedProject.value = JSON.parse(storedProject);
}
}
return selectedProject.value;
};
// 프로젝트 리스트가 변경될 때 자동으로 반응
watch(activeProjectList, (newList) => {
// 선택된 프로젝트가 없고 목록이 있는 경우
if (!selectedProject.value && newList.length > 0) {
// 사용자가 속한 프로젝트가 있는지 먼저 확인
if (activeMemberProjectList.value.length > 0) {
setSelectedProject(activeMemberProjectList.value[0]);
} else {
setSelectedProject(newList[0]);
}
}
});
watch(activeMemberProjectList, (newList) => {
if (newList.length > 0) {
// 현재 선택된 프로젝트가 없는 경우 첫 번째 항목 선택
if (!selectedProject.value) {
setSelectedProject(newList[0]);
} else {
// 선택된 프로젝트가 있는 경우 목록에 있는지 확인
const exists = newList.some(project => project.PROJCTSEQ === selectedProject.value.PROJCTSEQ);
if (!exists) {
setSelectedProject(newList[0]);
}
}
}
});
return {
projectList, // 종료된 프로젝트 포함한 모든 프로젝트
activeProjectList, // 진행 중인 프로젝트만
memberProjectList, // 사용자가 속한 모든 프로젝트
activeMemberProjectList, // 사용자가 속한 진행 중인 프로젝트
selectedProject,
getProjectList,
loadAllProjectLists, // 모든 프로젝트 목록 한번에 로드
getMemberProjects,
setSelectedProject,
getSelectedProject,
};
});

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

@ -1,13 +0,0 @@
<template>
Error
</template>
<script>
export default {
}
</script>
<style>
</style>

71
src/views/LoadingPage.vue Normal file
View File

@ -0,0 +1,71 @@
<template>
<div v-if="isLoading" class="loading-overlay">
<div class="loading-container">
<div class="spinner">
<img src="/img/icons/loading.png" class="loading-img" />
</div>
<p class="loading-text">LOADING...</p>
</div>
</div>
</template>
<script setup>
import { defineProps } from "vue";
defineProps({
isLoading: Boolean, //
});
</script>
<style scoped>
/* 회색 배경으로 클릭 방지 */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4); /* 회색 반투명 */
display: flex;
align-items: center;
justify-content: center;
z-index: 9999; /* 가장 위에 위치 */
pointer-events: auto; /* 모든 클릭 방지 */
}
/* 로딩 컨테이너 */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 20px;
background: none;
border-radius: 10px;
}
.loading-img {
width: 80px; /* 원하는 크기로 조정 */
height: 80px;
}
/* 빙글빙글 돌아가는 스피너 */
.spinner {
font-size: 50px;
animation: spin 2.2s linear infinite;
}
/* 로딩 텍스트 */
.loading-text {
margin-top: 10px;
font-size: 18px;
font-weight: bold;
color: #ffffff;
}
/* 회전 애니메이션 */
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,120 @@
<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="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>
import { ref, onMounted } from 'vue';
import axios from '@api';
import { useToastStore } from '@s/toastStore';
const users = ref([]);
const toastStore = useToastStore();
const baseUrl = axios.defaults.baseURL.replace(/api\/$/, "");
const defaultProfile = "/img/icons/icon.png";
const allowedUserId = 1; // ID ( )
//
async function fetchUsers() {
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;
}
//
function setDefaultImage(event) {
event.target.src = defaultProfile;
}
//
async function handleToggle(event, user) {
// Prevent the default checkbox toggle behavior
event.preventDefault();
// : ( )
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

@ -10,13 +10,22 @@
<div class="col-xl-12">
<div class="card-body">
<!-- 제목 입력 -->
<FormInput title="제목" name="title" :is-essential="true" :is-alert="titleAlert" v-model="title" />
<FormInput
title="제목"
name="title"
:is-essential="true"
:is-alert="titleAlert"
v-model="title"
@update:alert="titleAlert = $event"
@input.once="validateTitle"
/>
<!-- 첨부파일 업로드 -->
<FormFile
title="첨부파일"
name="files"
:is-alert="attachFilesAlert"
:key="autoIncrement"
@update:data="handleFileUpload"
@update:isValid="isFileValid = $event"
/>
@ -42,25 +51,27 @@
<div class="mb-4">
<label for="html5-tel-input" class="col-md-2 col-form-label">
내용
<span class="text-red">*</span>
<div class="invalid-feedback" :class="contentAlert ? 'display-block' : ''">내용을 확인해주세요.</div>
<span class="text-danger">*</span>
</label>
<div class="col-md-12">
<QEditor
v-if="contentLoaded"
@update:data="content = $event"
@update:data="handleEditorDataUpdate"
@update:imageUrls="imageUrls = $event"
@update:uploadedImgList="handleUpdateEditorImg"
@update:deleteImgIndexList="handleDeleteEditorImg"
:initialData="content"
/>
</div>
<div v-if="contentAlert" class="invalid-feedback d-block">내용을 확인해주세요.</div>
</div>
<!-- 버튼 -->
<div class="mb-4 d-flex justify-content-end">
<button type="button" class="btn btn-info right" @click="goList">
<button type="button" class="btn btn-info right" @click="goBack">
<i class="bx bx-left-arrow-alt"></i>
</button>
<button type="button" class="btn btn-primary ms-1" @click="updateBoard">
<button type="button" class="btn btn-primary ms-1" :disabled="!isChanged" @click="updateBoard">
<i class="bx bx-check"></i>
</button>
</div>
@ -74,13 +85,22 @@
import QEditor from '@c/editor/QEditor.vue';
import FormInput from '@c/input/FormInput.vue';
import FormFile from '@c/input/FormFile.vue';
import { ref, onMounted, computed, watch } from 'vue';
import { ref, onMounted, computed, watch, inject } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useToastStore } from '@s/toastStore';
import { useBoardAccessStore } from '@s/useBoardAccessStore';
import axios from '@api';
import Quill from 'quill';
//
const $common = inject('common');
const toastStore = useToastStore();
const accessStore = useBoardAccessStore();
//
const title = ref('');
const content = ref('');
const content = ref({ ops: [] });
const autoIncrement = ref(0);
//
const titleAlert = ref(false);
@ -100,85 +120,163 @@
const attachFilesAlert = ref(false);
const isFileValid = ref(true);
const delFileIdx = ref([]); // ID
const additionalFiles = ref([]); //
const editorUploadedImgList = ref([]);
const editorDeleteImgList = ref([]);
const originalTitle = ref('');
const originalContent = ref({});
const originalFiles = ref([]);
const contentInitialized = ref(false);
//
const isFirstContentUpdate = ref(true);
// ( )
const handleEditorDataUpdate = data => {
content.value = data;
if (isFirstContentUpdate.value) {
originalContent.value = structuredClone(data);
isFirstContentUpdate.value = false;
contentInitialized.value = true;
}
};
// isDeltaChanged ( diff , , )
function isDeltaChanged(current, original) {
const Delta = Quill.import('delta');
const currentDelta = new Delta(current || []);
const originalDelta = new Delta(original || []);
//
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);
// 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);
const imgsCurrent = getImages(currentDelta);
const imgsOriginal = getImages(originalDelta);
const vidsCurrent = getVideos(currentDelta);
const vidsOriginal = getVideos(originalDelta);
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) || //
delFileIdx.value.length > 0 || //
!isSameFiles(
attachFiles.value.filter(f => f.id), //
originalFiles.value
);
return isTitleChanged || isContentChanged || isFilesChanged;
});
//
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;
});
}
//
const fetchBoardDetails = async () => {
try {
const response = await axios.get(`board/${currentBoardId.value}`);
const data = response.data.data;
//
if (data.hasAttachment && data.attachments.length > 0) {
attachFiles.value = addDisplayFileName([...data.attachments]);
}
//
title.value = data.title || '제목 없음';
content.value = data.content || '내용 없음';
contentLoaded.value = true;
} catch (error) {
console.error('게시물 가져오기 오류:', error.response || error.message);
let password = accessStore.password;
const params = {
password: `${password}` || '',
};
const { data } = await axios.post(`board/${currentBoardId.value}`, params);
if (data.code !== 200) {
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 || { ops: [] };
originalTitle.value = title.value;
originalContent.value = structuredClone(boardData.content);
contentInitialized.value = true;
contentLoaded.value = true;
};
const handleUpdateEditorImg = item => {
editorUploadedImgList.value = item;
};
const handleDeleteEditorImg = item => {
editorDeleteImgList.value = item;
};
//
const addDisplayFileName = fileInfos =>
fileInfos.map(file => ({
...file,
name: `${file.originalName}.${file.extension}`,
name: `${file.originalName}.${file.extension}`
}));
//
//
const goList = () => {
router.push('/board');
accessStore.$reset();
router.back();
};
//
const updateBoard = async () => {
//
if (!title.value) {
titleAlert.value = true;
return;
}
titleAlert.value = false;
//
const goBack = () => {
accessStore.$reset();
router.back();
};
if (!content.value) {
contentAlert.value = true;
return;
}
contentAlert.value = false;
// ( : , , )
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);
};
try {
//
const boardData = {
LOCBRDTTL: title.value,
LOCBRDCON: JSON.stringify(content.value),
LOCBRDSEQ: currentBoardId.value,
};
if (delFileIdx.value && delFileIdx.value.length > 0) {
boardData.delFileIdx = [...delFileIdx.value];
//
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 = '';
}
const fileArray = newFileFilter(attachFiles);
const formData = new FormData();
Object.entries(boardData).forEach(([key, value]) => {
formData.append(key, value);
});
fileArray.forEach((file, idx) => {
formData.append('files', file);
});
await axios.put(`board/${currentBoardId.value}`, formData, { isFormData: true });
alert('게시물이 수정되었습니다.');
goList();
} catch (error) {
console.error('게시물 수정 중 오류 발생:', error);
alert('게시물 수정에 실패했습니다.');
return true;
} else {
return false;
}
};
@ -187,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;
@ -198,15 +295,16 @@
}
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 = '';
}
autoIncrement.value++;
};
watch(attachFiles, () => {
@ -219,8 +317,52 @@
};
////////////////// fileSection[E] ////////////////////
/** content 변경 감지 (deep 옵션 추가) */
watch(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
};
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();
Object.entries(boardData).forEach(([key, value]) => {
formData.append(key, value);
});
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');
goList();
} else {
toastStore.onToast('게시물 수정에 실패했습니다.', 'e');
}
};
//
onMounted(() => {
onMounted(async () => {
if (currentBoardId.value) {
fetchBoardDetails();
} else {
@ -228,10 +370,3 @@
}
});
</script>
<style>
.text-red {
color: red;
text-align: center;
}
</style>

Some files were not shown because too many files have changed in this diff Show More