@@ -8,25 +8,29 @@ import {Button, Card, CardBody, Checkbox, Divider, Input, Select, SelectItem} fr
88const API = axios . create ( {
99 baseURL : ( process . env . NEXT_PUBLIC_BASE_API_URL ?. replace ( / \/ $ / , '' ) || 'http://localhost:8080' ) + '/core-attendance/meetings' ,
1010 timeout : 15000 ,
11- withCredentials : true ,
11+ withCredentials : true , // refresh_token 쿠키 전송
1212} ) ;
1313
14- // 액세스 토큰 부착 & 401→refresh 재시도(간단 버전)
15- const getAccessToken = ( ) => localStorage . getItem ( 'access_token' ) ;
14+ // 액세스 토큰 부착 & 401 → refresh 재시도 (간단 버전)
15+ const getAccessToken = ( ) => ( typeof window !== 'undefined' ? localStorage . getItem ( 'access_token' ) : null ) ;
16+
1617API . interceptors . request . use ( ( config ) => {
1718 const t = getAccessToken ( ) ;
18- if ( t ) {
19+ // 'undefined' / 'null' 문자열 방어
20+ if ( t && t !== 'undefined' && t !== 'null' ) {
1921 config . headers = config . headers || { } ;
2022 config . headers . Authorization = `Bearer ${ t } ` ;
2123 }
2224 return config ;
2325} ) ;
26+
2427API . interceptors . response . use ( ( r ) => r , async ( err ) => {
2528 const original = err . config || { } ;
2629 if ( err . response ?. status === 401 && ! original . __retry ) {
2730 original . __retry = true ;
2831 const base = process . env . NEXT_PUBLIC_BASE_API_URL ?. replace ( / \/ $ / , '' ) || 'http://localhost:8080' ;
2932 try {
33+ // refresh 호출 (쿠키 필요)
3034 const res = await axios . post ( `${ base } /auth/refresh` , null , { withCredentials : true } ) ;
3135 const newAccess = res . data ?. data ?. accessToken ;
3236 if ( newAccess ) {
@@ -43,21 +47,21 @@ API.interceptors.response.use((r) => r, async (err) => {
4347} ) ;
4448
4549const api = {
46- // Dates
47- getDates : async ( ) => ( await API . get ( `/` ) ) . data . data , // { dates: [...] }
48- addDate : async ( date ) => ( await API . post ( `/` , { date} ) ) . data . data ,
49- deleteDate : async ( date ) => ( await API . delete ( `/${ date } ` ) ) . data . data ,
50+ getDates : async ( ) => ( await API . get ( `` ) ) . data . data , // { dates: [...] }
51+ addDate : async ( date ) => ( await API . post ( `` , { date} ) ) . data . data ,
52+ deleteDate : async ( date ) => ( await API . delete ( `${ date } ` ) ) . data . data ,
5053
5154 // Members (전체 팀 포함)
52- getMembers : async ( date ) => ( await API . get ( `/ ${ date } /members` ) ) . data . data , // [{ userId,name,team,present,... }]
55+ getMembers : async ( date ) => ( await API . get ( `${ date } /members` ) ) . data . data , // [{ userId,name,team,present,... }]
5356
5457 // Batch save
55- saveAttendance : async ( date , userIds , present ) => ( await API . put ( `/${ date } /attendance` , {
56- userIds, present
58+ saveAttendance : async ( date , userIds , present ) => ( await API . put ( `${ date } /attendance` , {
59+ userIds,
60+ present
5761 } ) ) . data . data ,
5862
5963 // Summary(옵션)
60- summary : async ( date ) => ( await API . get ( `/ ${ date } /summary` ) ) . data . data ,
64+ summary : async ( date ) => ( await API . get ( `${ date } /summary` ) ) . data . data ,
6165} ;
6266
6367/** ===== 유틸 ===== */
@@ -234,122 +238,129 @@ export default function AttendancePage() {
234238 } ;
235239
236240 return ( < div className = "flex flex-col max-w-[1100px] mx-auto min-h-[100svh] py-16 px-6" >
237- < h1 className = "font-bold mb-6 text-4xl tablet:text-3xl mobile:text-2xl" > 출석 관리</ h1 >
241+ < h1 className = "font-bold mb-6 text-4xl tablet:text-3xl mobile:text-2xl" > 출석 관리</ h1 >
242+
243+ < div className = "grid grid-cols-1 md:grid-cols-3 gap-4" >
244+ { /* 날짜 */ }
245+ < Card >
246+ < CardBody className = "gap-3" >
247+ < div className = "flex items-center justify-between" >
248+ < b > 날짜</ b >
249+ < Button size = "sm" color = "primary" onPress = { addToday } >
250+ 오늘 추가
251+ </ Button >
252+ </ div >
238253
239- < div className = "grid grid-cols-1 md:grid-cols-3 gap-4" >
240- { /* 날짜 */ }
241- < Card >
242- < CardBody className = "gap-3" >
243- < div className = "flex items-center justify-between" >
244- < b > 날짜</ b >
245- < Button size = "sm" color = "primary" onPress = { addToday } >
246- 오늘 추가
247- </ Button >
248- </ div >
254+ { /* date picker는 유지(빠른 변경용) */ }
255+ < Input type = "date" value = { date } onChange = { ( e ) => setDate ( e . target . value ) } />
256+ < Divider />
257+ < div className = "max-h-[180px] overflow-auto space-y-2" >
258+ { dates . map ( ( d ) => ( < div key = { d } className = "flex items-center justify-between" >
259+ < Button size = "sm" variant = "light" onPress = { ( ) => setDate ( d ) } >
260+ { d === date ? < b > { d } </ b > : d }
261+ </ Button >
262+ < Button size = "sm" color = "danger" variant = "flat" onPress = { ( ) => removeDate ( d ) } >
263+ 삭제
264+ </ Button >
265+ </ div > ) ) }
266+ { dates . length === 0 && ( < div className = "text-sm text-foreground-500" > 등록된 날짜가 없습니다.</ div > ) }
267+ </ div >
268+ </ CardBody >
269+ </ Card >
270+
271+ { /* 필터 & 저장 */ }
272+ < Card >
273+ < CardBody className = "gap-3" >
274+ < div className = "flex items-center justify-between" >
275+ < b > 필터 / 저장</ b >
276+ < Button
277+ size = "sm"
278+ color = "primary"
279+ variant = "flat"
280+ onPress = { saveSnapshot }
281+ isDisabled = { ! dirty || ! members . length }
282+ >
283+ 저장{ dirty ? ' *' : '' }
284+ </ Button >
285+ </ div >
249286
250- { /* date picker는 유지(빠른 변경용) */ }
251- < Input type = "date" value = { date } onChange = { ( e ) => setDate ( e . target . value ) } />
252- < Divider />
253- < div className = "max-h-[180px] overflow-auto space-y-2" >
254- { dates . map ( ( d ) => ( < div key = { d } className = "flex items-center justify-between" >
255- < Button size = "sm" variant = "light" onPress = { ( ) => setDate ( d ) } >
256- { d === date ? < b > { d } </ b > : d }
287+ < Select
288+ label = "팀(클라이언트 필터)"
289+ selectedKeys = { teamFilter ? new Set ( [ teamFilter ] ) : new Set ( ) }
290+ onSelectionChange = { ( keys ) => {
291+ const first = String ( Array . from ( keys || [ ] ) [ 0 ] ?? '' ) ;
292+ setTeamFilter ( first || '' ) ;
293+ } }
294+ variant = "bordered"
295+ >
296+ { teamOptions . map ( ( t ) => ( < SelectItem key = { t } value = { t } >
297+ { t }
298+ </ SelectItem > ) ) }
299+ </ Select >
300+
301+ < Input placeholder = "이름 검색" value = { filter } onValueChange = { setFilter } size = "sm" />
302+
303+ < div className = "flex gap-2" >
304+ < Button size = "sm" onPress = { ( ) => checkAll ( true ) } color = "success" variant = "flat" >
305+ (필터된) 전체 체크
257306 </ Button >
258- < Button size = "sm" color = "danger" variant = "flat" onPress = { ( ) => removeDate ( d ) } >
259- 삭제
307+ < Button size = "sm" onPress = { ( ) => checkAll ( false ) } color = "warning" variant = "flat" >
308+ (필터된) 전체 해제
260309 </ Button >
261- </ div > ) ) }
262- { dates . length === 0 && ( < div className = "text-sm text-foreground-500" > 등록된 날짜가 없습니다.</ div > ) }
263- </ div >
264- </ CardBody >
265- </ Card >
266-
267- { /* 필터 & 저장 */ }
268- < Card >
310+ </ div >
311+ </ CardBody >
312+ </ Card >
313+
314+ { /* 요약 */ }
315+ < Card >
316+ < CardBody className = "gap-3" >
317+ < b > 요약</ b >
318+ { summary ? ( < div className = "text-sm" >
319+ < div className = "mb-2" > 전체 { summary . present } / { summary . total } </ div >
320+ < Divider />
321+ < div className = "mt-2 space-y-1" >
322+ { summary . perTeam . map ( ( ts ) => (
323+ < div key = { ts . teamId } className = "flex items-center justify-between" >
324+ < span > { ts . teamName } </ span >
325+ < span > { ts . present } / { ts . total } </ span >
326+ </ div > ) ) }
327+ </ div >
328+ </ div > ) : ( < div className = "text-foreground-500 text-sm" > 로딩...</ div > ) }
329+ </ CardBody >
330+ </ Card >
331+ </ div >
332+
333+ { /* 팀원 목록 */ }
334+ < Card className = "mt-6" >
269335 < CardBody className = "gap-3" >
270336 < div className = "flex items-center justify-between" >
271- < b > 필터 / 저장</ b >
272- < Button size = "sm" color = "primary" variant = "flat" onPress = { saveSnapshot }
273- isDisabled = { ! dirty || ! members . length } >
274- 저장{ dirty ? ' *' : '' }
275- </ Button >
337+ < div >
338+ < b > 팀원</ b >
339+ < div className = "text-xs text-foreground-500" >
340+ { date } · { teamFilter || '전체 팀' }
341+ </ div >
342+ </ div >
276343 </ div >
277344
278- < Select
279- label = "팀(클라이언트 필터)"
280- selectedKeys = { teamFilter ? new Set ( [ teamFilter ] ) : new Set ( ) }
281- onSelectionChange = { ( keys ) => {
282- const first = String ( Array . from ( keys || [ ] ) [ 0 ] ?? '' ) ;
283- setTeamFilter ( first || '' ) ;
284- } }
285- variant = "bordered"
286- >
287- { teamOptions . map ( ( t ) => ( < SelectItem key = { t } value = { t } >
288- { t }
289- </ SelectItem > ) ) }
290- </ Select >
291-
292- < Input placeholder = "이름 검색" value = { filter } onValueChange = { setFilter } size = "sm" />
293-
294- < div className = "flex gap-2" >
295- < Button size = "sm" onPress = { ( ) => checkAll ( true ) } color = "success" variant = "flat" >
296- (필터된) 전체 체크
297- </ Button >
298- < Button size = "sm" onPress = { ( ) => checkAll ( false ) } color = "warning" variant = "flat" >
299- (필터된) 전체 해제
300- </ Button >
301- </ div >
302- </ CardBody >
303- </ Card >
345+ < Divider />
304346
305- { /* 요약 */ }
306- < Card >
307- < CardBody className = "gap-3" >
308- < b > 요약</ b >
309- { summary ? ( < div className = "text-sm" >
310- < div className = "mb-2" > 전체 { summary . present } / { summary . total } </ div >
311- < Divider />
312- < div className = "mt-2 space-y-1" >
313- { summary . perTeam . map ( ( ts ) => (
314- < div key = { ts . teamId } className = "flex items-center justify-between" >
315- < span > { ts . teamName } </ span >
316- < span > { ts . present } / { ts . total } </ span >
317- </ div > ) ) }
318- </ div >
319- </ div > ) : ( < div className = "text-foreground-500 text-sm" > 로딩...</ div > ) }
347+ < div className = "max-h-[460px] overflow-auto" >
348+ { filteredMembers . map ( ( m ) => {
349+ const id = String ( m . userId ) ;
350+ const checked = presentSet . has ( id ) ;
351+ return ( < div key = { id } className = "flex items-center justify-between py-2" >
352+ < div className = "flex items-center gap-3" >
353+ < Checkbox isSelected = { checked } onValueChange = { ( ) => toggleMember ( m ) } >
354+ { m . name } { ' ' }
355+ < span className = "text-xs text-foreground-500 ml-2" > ({ m . team } )</ span >
356+ </ Checkbox >
357+ </ div >
358+ </ div > ) ;
359+ } ) }
360+ { filteredMembers . length === 0 && (
361+ < div className = "text-sm text-foreground-500 py-3" > 표시할 팀원이 없습니다.</ div > ) }
362+ </ div >
320363 </ CardBody >
321364 </ Card >
322- </ div >
323-
324- { /* 팀원 목록 */ }
325- < Card className = "mt-6" >
326- < CardBody className = "gap-3" >
327- < div className = "flex items-center justify-between" >
328- < div >
329- < b > 팀원</ b >
330- < div className = "text-xs text-foreground-500" > { date } · { teamFilter || '전체 팀' } </ div >
331- </ div >
332- </ div >
333-
334- < Divider />
335-
336- < div className = "max-h-[460px] overflow-auto" >
337- { filteredMembers . map ( ( m ) => {
338- const id = String ( m . userId ) ;
339- const checked = presentSet . has ( id ) ;
340- return ( < div key = { id } className = "flex items-center justify-between py-2" >
341- < div className = "flex items-center gap-3" >
342- < Checkbox isSelected = { checked } onValueChange = { ( ) => toggleMember ( m ) } >
343- { m . name } < span
344- className = "text-xs text-foreground-500 ml-2" > ({ m . team } )</ span >
345- </ Checkbox >
346- </ div >
347- </ div > ) ;
348- } ) }
349- { filteredMembers . length === 0 && (
350- < div className = "text-sm text-foreground-500 py-3" > 표시할 팀원이 없습니다.</ div > ) }
351- </ div >
352- </ CardBody >
353- </ Card >
354- </ div > ) ;
365+ </ div > ) ;
355366}
0 commit comments