11'use client' ;
22
3- import { useEffect , useMemo , useState } from 'react' ;
3+ import { useEffect , useMemo , useRef , useState } from 'react' ;
44import { Button , Card , CardBody , Checkbox , Divider , Input , Select , SelectItem } from '@nextui-org/react' ;
55import { useAuthenticatedApi } from '@/hooks/useAuthenticatedApi' ;
66
@@ -15,7 +15,7 @@ const setQS = (entries) => {
1515} ;
1616
1717export default function AttendancePage ( ) {
18- const { apiClient} = useAuthenticatedApi ( ) ; // ✅ 인증 포함 Axios 인스턴스
18+ const { apiClient} = useAuthenticatedApi ( ) ;
1919
2020 // URL state
2121 const [ date , setDate ] = useState ( typeof window !== 'undefined' ? getQS ( 'date' ) || ymd ( ) : ymd ( ) ) ;
@@ -28,10 +28,13 @@ export default function AttendancePage() {
2828
2929 // UI
3030 const [ filter , setFilter ] = useState ( '' ) ;
31- const [ teamFilter , setTeamFilter ] = useState ( '' ) ; // 팀 라벨 기준 필터 (''=전체)
31+ const [ teamFilter , setTeamFilter ] = useState ( '' ) ; // 팀 라벨 기준 (''=전체)
3232 const [ presentSet , setPresentSet ] = useState ( new Set ( ) ) ; // Set<string(userId)>
3333 const [ dirty , setDirty ] = useState ( false ) ;
3434
35+ // 초기 상태(서버 로드 직후 present 사용자들) → Δ 저장용
36+ const initialPresentSetRef = useRef ( new Set ( ) ) ;
37+
3538 /** ===== API 래퍼 ===== */
3639 const api = {
3740 getDates : async ( ) => ( await apiClient . get ( '/core-attendance/meetings' ) ) . data . data , // { dates: [...] }
@@ -68,13 +71,13 @@ export default function AttendancePage() {
6871 // eslint-disable-next-line react-hooks/exhaustive-deps
6972 } , [ ] ) ;
7073
71- /** 팀 로드 (리드=본인 팀만 / 관리자 =전체) */
74+ /** 팀 로드 (리드=본인 팀만 / 오거나이저·어드민 =전체) */
7275 useEffect ( ( ) => {
7376 ( async ( ) => {
7477 try {
7578 const list = await api . getTeams ( ) ;
7679 setTeams ( Array . isArray ( list ) ? list : [ ] ) ;
77- // 자동 선택 UX(리드 등 팀 1개만 내려오면 자동 선택)
80+ // 리드( 팀 1개만 내려올 때) 자동 선택
7881 if ( ! teamFilter && list ?. length === 1 ) setTeamFilter ( list [ 0 ] . name ) ;
7982 } catch {
8083 setTeams ( [ ] ) ;
@@ -93,10 +96,12 @@ export default function AttendancePage() {
9396 const init = new Set ( ) ;
9497 rows . forEach ( ( r ) => r . present && init . add ( String ( r . userId ) ) ) ;
9598 setPresentSet ( init ) ;
99+ initialPresentSetRef . current = new Set ( init ) ; // 초기 상태 보관
96100 setDirty ( false ) ;
97101 } catch {
98102 setMembers ( [ ] ) ;
99103 setPresentSet ( new Set ( ) ) ;
104+ initialPresentSetRef . current = new Set ( ) ;
100105 setDirty ( false ) ;
101106 }
102107 } ) ( ) ;
@@ -117,6 +122,9 @@ export default function AttendancePage() {
117122 // eslint-disable-next-line react-hooks/exhaustive-deps
118123 } , [ date ] ) ;
119124
125+ /** “전체” 옵션은 팀이 2개 이상 전달될 때(= 오거나이저/어드민)만 노출 */
126+ const showAllOption = teams . length > 1 ;
127+
120128 /** 팀 옵션(라벨) */
121129 const teamOptions = useMemo ( ( ) => Array . from ( new Set ( teams . map ( ( t ) => t . name ) ) ) . filter ( Boolean ) , [ teams ] ) ;
122130
@@ -142,6 +150,21 @@ export default function AttendancePage() {
142150 }
143151 } ;
144152
153+ const addSelectedDate = async ( ) => {
154+ try {
155+ if ( ! / ^ \d { 4 } - \d { 2 } - \d { 2 } $ / . test ( date ) ) {
156+ alert ( '날짜 형식이 올바르지 않습니다. YYYY-MM-DD' ) ;
157+ return ;
158+ }
159+ await api . addDate ( date ) ;
160+ const dl = await api . getDates ( ) ;
161+ setDates ( dl . dates ) ;
162+ alert ( `"${ date } "가 추가되었습니다.` ) ;
163+ } catch {
164+ alert ( '선택 날짜 추가에 실패했습니다.' ) ;
165+ }
166+ } ;
167+
145168 const removeDate = async ( d ) => {
146169 try {
147170 await api . deleteDate ( d ) ;
@@ -176,17 +199,36 @@ export default function AttendancePage() {
176199 setDirty ( true ) ;
177200 } ;
178201
179- /** 저장(스냅샷) – present=true & present=false 두 번 호출 (서버는 List<Long> 기대 → 숫자로 전송) */
202+ /** 저장(Δ만 전송) */
180203 const saveSnapshot = async ( ) => {
204+ // 현재/초기 출석 집합
205+ const now = presentSet ;
206+ const init = initialPresentSetRef . current ;
207+
208+ // Δ 계산
209+ const added = [ ] ; // now ∖ init → present=true
210+ const removed = [ ] ; // init ∖ now → present=false
211+
181212 const allIdsStr = members . map ( ( m ) => String ( m . userId ) ) ;
182- const presentIdsStr = allIdsStr . filter ( ( id ) => presentSet . has ( id ) ) ;
183- const absentIdsStr = allIdsStr . filter ( ( id ) => ! presentSet . has ( id ) ) ;
184- const presentIds = presentIdsStr . map ( ( s ) => Number ( s ) ) ;
185- const absentIds = absentIdsStr . map ( ( s ) => Number ( s ) ) ;
213+ // members에 있는 대상들만 비교 (안전)
214+ for ( const id of allIdsStr ) {
215+ const inNow = now . has ( id ) ;
216+ const inInit = init . has ( id ) ;
217+ if ( inNow && ! inInit ) added . push ( Number ( id ) ) ;
218+ if ( ! inNow && inInit ) removed . push ( Number ( id ) ) ;
219+ }
220+
221+ if ( ! added . length && ! removed . length ) {
222+ setDirty ( false ) ;
223+ alert ( '변경된 내용이 없습니다.' ) ;
224+ return ;
225+ }
186226
187227 try {
188- if ( presentIds . length ) await api . saveAttendance ( date , presentIds , true ) ;
189- if ( absentIds . length ) await api . saveAttendance ( date , absentIds , false ) ;
228+ if ( added . length ) await api . saveAttendance ( date , added , true ) ;
229+ if ( removed . length ) await api . saveAttendance ( date , removed , false ) ;
230+ // 저장 성공 → 초기 상태 갱신
231+ initialPresentSetRef . current = new Set ( presentSet ) ;
190232 setDirty ( false ) ;
191233 await refreshSummary ( ) ;
192234 alert ( '저장되었습니다.' ) ;
@@ -212,9 +254,11 @@ export default function AttendancePage() {
212254 < CardBody className = "gap-3 text-white" >
213255 < div className = "flex items-center justify-between" >
214256 < b > 날짜</ b >
215- < Button size = "sm" color = "primary" onPress = { addToday } >
216- 오늘 추가
217- </ Button >
257+ < div className = "flex gap-2" >
258+ < Button size = "sm" color = "primary" onPress = { addToday } > 오늘 추가</ Button >
259+ < Button size = "sm" color = "secondary" variant = "flat" onPress = { addSelectedDate } > 선택 날짜
260+ 추가</ Button >
261+ </ div >
218262 </ div >
219263
220264 < Input
@@ -258,25 +302,26 @@ export default function AttendancePage() {
258302 </ Button >
259303 </ div >
260304
261- { /* 팀 선택(“전체” 포함 ) */ }
305+ { /* 팀 선택(오거나이저/어드민만 “전체” 노출 ) */ }
262306 < Select
263307 label = "팀(클라이언트 필터)"
264- selectedKeys = { teamFilter ? new Set ( [ teamFilter ] ) : new Set ( [ '전체' ] ) }
308+ selectedKeys = { showAllOption ? ( teamFilter ? new Set ( [ teamFilter ] ) : new Set ( [ '__ALL__' ] ) ) : ( teamFilter ? new Set ( [ teamFilter ] ) : new Set ( ) ) }
265309 onSelectionChange = { ( keys ) => {
266310 const first = String ( Array . from ( keys || [ ] ) [ 0 ] ?? '' ) ;
267- setTeamFilter ( first === '전체' ? '' : first ) ;
311+ if ( showAllOption && first === '__ALL__' ) setTeamFilter ( '' ) ; else setTeamFilter ( first || '' ) ;
268312 } }
269313 variant = "bordered"
270314 classNames = { {
271315 trigger : 'bg-default-200/50 dark:bg-default/60' ,
272316 label : 'text-black/50 dark:text-white/90' ,
273317 value : 'text-black/90 dark:text-white/90' ,
274318 popoverContent : 'bg-default-100 dark:bg-default-50' ,
319+ listbox : 'text-white' ,
275320 } }
276321 >
277- < SelectItem key = "전체 " value = "전체 " className = "text-white" >
278- 전체
279- </ SelectItem >
322+ { showAllOption && ( < SelectItem key = "__ALL__ " value = "__ALL__ " className = "text-white" >
323+ 전체
324+ </ SelectItem > ) }
280325 { teamOptions . map ( ( name ) => ( < SelectItem key = { name } value = { name } className = "text-white" >
281326 { name }
282327 </ SelectItem > ) ) }
@@ -335,7 +380,7 @@ export default function AttendancePage() {
335380 < div >
336381 < b > 팀원</ b >
337382 < div className = "text-xs text-foreground-500" >
338- { date } · { teamFilter || '전체 팀' }
383+ { date } · { teamFilter || ( showAllOption ? '전체 팀' : ( teams [ 0 ] ?. name ?? '' ) ) }
339384 </ div >
340385 </ div >
341386 </ div >
0 commit comments