11'use client' ;
22
33import { useEffect , useMemo , useState } from 'react' ;
4- import axios from 'axios' ;
54import { Button , Card , CardBody , Checkbox , Divider , Input , Select , SelectItem } from '@nextui-org/react' ;
6-
7- /** ===== API ===== */
8- const API = axios . create ( {
9- baseURL : ( process . env . NEXT_PUBLIC_BASE_API_URL ?. replace ( / \/ $ / , '' ) || 'http://localhost:8080' ) + '/core-attendance/meetings' ,
10- timeout : 15000 ,
11- withCredentials : true , // refresh_token 쿠키 전송
12- } ) ;
13-
14- // 액세스 토큰 부착 & 401 → refresh 재시도 (간단 버전)
15- const getAccessToken = ( ) => ( typeof window !== 'undefined' ? localStorage . getItem ( 'access_token' ) : null ) ;
16-
17- API . interceptors . request . use ( ( config ) => {
18- const t = getAccessToken ( ) ;
19- // 'undefined' / 'null' 문자열 방어
20- if ( t && t !== 'undefined' && t !== 'null' ) {
21- config . headers = config . headers || { } ;
22- config . headers . Authorization = `Bearer ${ t } ` ;
23- }
24- return config ;
25- } ) ;
26-
27- API . interceptors . response . use ( ( r ) => r , async ( err ) => {
28- const original = err . config || { } ;
29- if ( err . response ?. status === 401 && ! original . __retry ) {
30- original . __retry = true ;
31- const base = process . env . NEXT_PUBLIC_BASE_API_URL ?. replace ( / \/ $ / , '' ) || 'http://localhost:8080' ;
32- try {
33- // refresh 호출 (쿠키 필요)
34- const res = await axios . post ( `${ base } /auth/refresh` , null , { withCredentials : true } ) ;
35- const newAccess = res . data ?. data ?. accessToken ;
36- if ( newAccess ) {
37- localStorage . setItem ( 'access_token' , newAccess ) ;
38- original . headers = original . headers || { } ;
39- original . headers . Authorization = `Bearer ${ newAccess } ` ;
40- return API . request ( original ) ;
41- }
42- } catch ( _ ) {
43- localStorage . removeItem ( 'access_token' ) ;
44- }
45- }
46- return Promise . reject ( err ) ;
47- } ) ;
48-
49- const api = {
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 ,
53-
54- // Members (전체 팀 포함)
55- getMembers : async ( date ) => ( await API . get ( `${ date } /members` ) ) . data . data , // [{ userId,name,team,present,... }]
56-
57- // Batch save
58- saveAttendance : async ( date , userIds , present ) => ( await API . put ( `${ date } /attendance` , {
59- userIds,
60- present
61- } ) ) . data . data ,
62-
63- // Summary(옵션)
64- summary : async ( date ) => ( await API . get ( `${ date } /summary` ) ) . data . data ,
65- } ;
5+ import { useAuthenticatedApi } from '@/hooks/useAuthenticatedApi' ;
666
677/** ===== 유틸 ===== */
688const ymd = ( d = new Date ( ) ) => d . toISOString ( ) . slice ( 0 , 10 ) ;
699const getQS = ( k ) => typeof window !== 'undefined' ? new URL ( window . location . href ) . searchParams . get ( k ) || '' : '' ;
7010const setQS = ( entries ) => {
7111 if ( typeof window === 'undefined' ) return ;
7212 const u = new URL ( window . location . href ) ;
73- Object . entries ( entries ) . forEach ( ( [ k , v ] ) => ( v ? u . searchParams . set ( k , v ) : u . searchParams . delete ( k ) ) ) ;
13+ Object . entries ( entries ) . forEach ( ( [ k , v ] ) => v ? u . searchParams . set ( k , v ) : u . searchParams . delete ( k ) ) ;
7414 window . history . replaceState ( { } , '' , u . toString ( ) ) ;
7515} ;
7616
7717export default function AttendancePage ( ) {
18+ const { apiClient} = useAuthenticatedApi ( ) ; // ✅ 인증 포함 Axios 인스턴스
19+
7820 // URL state
7921 const [ date , setDate ] = useState ( typeof window !== 'undefined' ? getQS ( 'date' ) || ymd ( ) : ymd ( ) ) ;
8022
@@ -89,6 +31,25 @@ export default function AttendancePage() {
8931 const [ presentSet , setPresentSet ] = useState ( new Set ( ) ) ;
9032 const [ dirty , setDirty ] = useState ( false ) ;
9133
34+ /** ===== API 래퍼 (인증 포함) ===== */
35+ const api = {
36+ // Dates
37+ getDates : async ( ) => ( await apiClient . get ( '/core-attendance/meetings/' ) ) . data . data , // { dates: [...] }
38+ addDate : async ( d ) => ( await apiClient . post ( '/core-attendance/meetings' , { date : d } ) ) . data . data ,
39+ deleteDate : async ( d ) => ( await apiClient . delete ( `/core-attendance/meetings/${ d } ` ) ) . data . data ,
40+
41+ // Members (전체 팀 포함)
42+ getMembers : async ( d ) => ( await apiClient . get ( `/core-attendance/meetings/${ d } /members` ) ) . data . data ,
43+
44+ // Batch save
45+ saveAttendance : async ( d , userIds , present ) => ( await apiClient . put ( `/core-attendance/meetings/${ d } /attendance` , {
46+ userIds, present,
47+ } ) ) . data . data ,
48+
49+ // Summary(옵션)
50+ summary : async ( d ) => ( await apiClient . get ( `/core-attendance/meetings/${ d } /summary` ) ) . data . data ,
51+ } ;
52+
9253 /** URL 동기화 */
9354 useEffect ( ( ) => {
9455 setQS ( { date} ) ;
@@ -125,7 +86,7 @@ export default function AttendancePage() {
12586 setDirty ( false ) ;
12687 }
12788 } ) ( ) ;
128- } , [ date ] ) ;
89+ } , [ date ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
12990
13091 /** 요약 로드(옵션) */
13192 useEffect ( ( ) => {
@@ -138,7 +99,7 @@ export default function AttendancePage() {
13899 setSummary ( null ) ;
139100 }
140101 } ) ( ) ;
141- } , [ date ] ) ;
102+ } , [ date ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
142103
143104 // 클라 필터링
144105 const teamOptions = useMemo ( ( ) => Array . from ( new Set ( members . map ( ( m ) => m . team ) ) ) . filter ( Boolean ) , [ members ] ) ;
@@ -238,129 +199,129 @@ export default function AttendancePage() {
238199 } ;
239200
240201 return ( < div className = "flex flex-col max-w-[1100px] mx-auto min-h-[100svh] py-16 px-6" >
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 >
253-
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 >
202+ < h1 className = "font-bold mb-6 text-4xl tablet:text-3xl mobile:text-2xl" > 출석 관리</ h1 >
286203
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" />
204+ < div className = "grid grid-cols-1 md:grid-cols-3 gap-4" >
205+ { /* 날짜 */ }
206+ < Card >
207+ < CardBody className = "gap-3" >
208+ < div className = "flex items-center justify-between" >
209+ < b > 날짜</ b >
210+ < Button size = "sm" color = "primary" onPress = { addToday } >
211+ 오늘 추가
212+ </ Button >
213+ </ div >
302214
303- < div className = "flex gap-2" >
304- < Button size = "sm" onPress = { ( ) => checkAll ( true ) } color = "success" variant = "flat" >
305- (필터된) 전체 체크
215+ { /* date picker는 유지(빠른 변경용) */ }
216+ < Input type = "date" value = { date } onChange = { ( e ) => setDate ( e . target . value ) } />
217+ < Divider />
218+ < div className = "max-h-[180px] overflow-auto space-y-2" >
219+ { dates . map ( ( d ) => ( < div key = { d } className = "flex items-center justify-between" >
220+ < Button size = "sm" variant = "light" onPress = { ( ) => setDate ( d ) } >
221+ { d === date ? < b > { d } </ b > : d }
306222 </ Button >
307- < Button size = "sm" onPress = { ( ) => checkAll ( false ) } color = "warning" variant = "flat" >
308- (필터된) 전체 해제
223+ < Button size = "sm" color = "danger" variant = "flat" onPress = { ( ) => removeDate ( d ) } >
224+ 삭제
309225 </ Button >
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" >
226+ </ div > ) ) }
227+ { dates . length === 0 && ( < div className = "text-sm text-foreground-500" > 등록된 날짜가 없습니다.</ div > ) }
228+ </ div >
229+ </ CardBody >
230+ </ Card >
231+
232+ { /* 필터 & 저장 */ }
233+ < Card >
335234 < CardBody className = "gap-3" >
336235 < div className = "flex items-center justify-between" >
337- < div >
338- < b > 팀원</ b >
339- < div className = "text-xs text-foreground-500" >
340- { date } · { teamFilter || '전체 팀' }
341- </ div >
342- </ div >
236+ < b > 필터 / 저장</ b >
237+ < Button
238+ size = "sm"
239+ color = "primary"
240+ variant = "flat"
241+ onPress = { saveSnapshot }
242+ isDisabled = { ! dirty || ! members . length }
243+ >
244+ 저장{ dirty ? ' *' : '' }
245+ </ Button >
343246 </ div >
344247
345- < Divider />
346-
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 > ) }
248+ < Select
249+ label = "팀(클라이언트 필터)"
250+ selectedKeys = { teamFilter ? new Set ( [ teamFilter ] ) : new Set ( ) }
251+ onSelectionChange = { ( keys ) => {
252+ const first = String ( Array . from ( keys || [ ] ) [ 0 ] ?? '' ) ;
253+ setTeamFilter ( first || '' ) ;
254+ } }
255+ variant = "bordered"
256+ >
257+ { teamOptions . map ( ( t ) => ( < SelectItem key = { t } value = { t } >
258+ { t }
259+ </ SelectItem > ) ) }
260+ </ Select >
261+
262+ < Input placeholder = "이름 검색" value = { filter } onValueChange = { setFilter } size = "sm" />
263+
264+ < div className = "flex gap-2" >
265+ < Button size = "sm" onPress = { ( ) => checkAll ( true ) } color = "success" variant = "flat" >
266+ (필터된) 전체 체크
267+ </ Button >
268+ < Button size = "sm" onPress = { ( ) => checkAll ( false ) } color = "warning" variant = "flat" >
269+ (필터된) 전체 해제
270+ </ Button >
362271 </ div >
363272 </ CardBody >
364273 </ Card >
365- </ div > ) ;
274+
275+ { /* 요약 */ }
276+ < Card >
277+ < CardBody className = "gap-3" >
278+ < b > 요약</ b >
279+ { summary ? ( < div className = "text-sm" >
280+ < div className = "mb-2" > 전체 { summary . present } / { summary . total } </ div >
281+ < Divider />
282+ < div className = "mt-2 space-y-1" >
283+ { summary . perTeam . map ( ( ts ) => (
284+ < div key = { ts . teamId } className = "flex items-center justify-between" >
285+ < span > { ts . teamName } </ span >
286+ < span > { ts . present } / { ts . total } </ span >
287+ </ div > ) ) }
288+ </ div >
289+ </ div > ) : ( < div className = "text-foreground-500 text-sm" > 로딩...</ div > ) }
290+ </ CardBody >
291+ </ Card >
292+ </ div >
293+
294+ { /* 팀원 목록 */ }
295+ < Card className = "mt-6" >
296+ < CardBody className = "gap-3" >
297+ < div className = "flex items-center justify-between" >
298+ < div >
299+ < b > 팀원</ b >
300+ < div className = "text-xs text-foreground-500" >
301+ { date } · { teamFilter || '전체 팀' }
302+ </ div >
303+ </ div >
304+ </ div >
305+
306+ < Divider />
307+
308+ < div className = "max-h-[460px] overflow-auto" >
309+ { filteredMembers . map ( ( m ) => {
310+ const id = String ( m . userId ) ;
311+ const checked = presentSet . has ( id ) ;
312+ return ( < div key = { id } className = "flex items-center justify-between py-2" >
313+ < div className = "flex items-center gap-3" >
314+ < Checkbox isSelected = { checked } onValueChange = { ( ) => toggleMember ( m ) } >
315+ { m . name } { ' ' }
316+ < span className = "text-xs text-foreground-500 ml-2" > ({ m . team } )</ span >
317+ </ Checkbox >
318+ </ div >
319+ </ div > ) ;
320+ } ) }
321+ { filteredMembers . length === 0 && (
322+ < div className = "text-sm text-foreground-500 py-3" > 표시할 팀원이 없습니다.</ div > ) }
323+ </ div >
324+ </ CardBody >
325+ </ Card >
326+ </ div > ) ;
366327}
0 commit comments