@@ -22,12 +22,13 @@ export default function AttendancePage() {
2222
2323 // data
2424 const [ dates , setDates ] = useState ( [ ] ) ;
25- const [ members , setMembers ] = useState ( [ ] ) ; // [{userId,name,team,present,...}]
25+ const [ teams , setTeams ] = useState ( [ ] ) ; // [{ id, name, lead? }]
26+ const [ members , setMembers ] = useState ( [ ] ) ; // [{userId,name,team(=라벨),present,...}]
2627 const [ summary , setSummary ] = useState ( null ) ;
2728
2829 // UI
2930 const [ filter , setFilter ] = useState ( '' ) ;
30- const [ teamFilter , setTeamFilter ] = useState ( '' ) ; // 클라 사이드 팀 필터
31+ const [ teamFilter , setTeamFilter ] = useState ( '' ) ; // 팀 라벨(=members[].team) 기준 필터
3132 const [ presentSet , setPresentSet ] = useState ( new Set ( ) ) ;
3233 const [ dirty , setDirty ] = useState ( false ) ;
3334
@@ -38,12 +39,16 @@ export default function AttendancePage() {
3839 addDate : async ( d ) => ( await apiClient . post ( '/core-attendance/meetings' , { date : d } ) ) . data . data ,
3940 deleteDate : async ( d ) => ( await apiClient . delete ( `/core-attendance/meetings/${ d } ` ) ) . data . data ,
4041
42+ // Teams
43+ getTeams : async ( ) => ( await apiClient . get ( '/core-attendance/meetings/teams' ) ) . data . data ,
44+
4145 // Members (전체 팀 포함)
4246 getMembers : async ( d ) => ( await apiClient . get ( `/core-attendance/meetings/${ d } /members` ) ) . data . data ,
4347
4448 // Batch save
4549 saveAttendance : async ( d , userIds , present ) => ( await apiClient . put ( `/core-attendance/meetings/${ d } /attendance` , {
46- userIds, present,
50+ userIds,
51+ present
4752 } ) ) . data . data ,
4853
4954 // Summary(옵션)
@@ -69,6 +74,21 @@ export default function AttendancePage() {
6974 // eslint-disable-next-line react-hooks/exhaustive-deps
7075 } , [ ] ) ;
7176
77+ /** 팀 로드 (리드=본인 팀만 / 관리자=전체) */
78+ useEffect ( ( ) => {
79+ ( async ( ) => {
80+ try {
81+ const list = await api . getTeams ( ) ;
82+ setTeams ( Array . isArray ( list ) ? list : [ ] ) ;
83+ // 선택된 필터가 없고, 서버가 1개만 보내줬다면 자동 선택(리드인 경우 UX)
84+ if ( ! teamFilter && list ?. length === 1 ) setTeamFilter ( list [ 0 ] . name ) ;
85+ } catch {
86+ setTeams ( [ ] ) ;
87+ }
88+ } ) ( ) ;
89+ // eslint-disable-next-line react-hooks/exhaustive-deps
90+ } , [ ] ) ;
91+
7292 /** 선택 날짜 → 멤버/출석 로드 */
7393 useEffect ( ( ) => {
7494 ( async ( ) => {
@@ -101,9 +121,10 @@ export default function AttendancePage() {
101121 } ) ( ) ;
102122 } , [ date ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
103123
104- // 클라 필터링
105- const teamOptions = useMemo ( ( ) => Array . from ( new Set ( members . map ( ( m ) => m . team ) ) ) . filter ( Boolean ) , [ members ] ) ;
124+ // 팀 옵션: 서버에서 내려준 팀 라벨(name) 사용 (members[].team과 동일한 라벨로 필터링)
125+ const teamOptions = useMemo ( ( ) => Array . from ( new Set ( teams . map ( ( t ) => t . name ) ) ) . filter ( Boolean ) , [ teams ] ) ;
106126
127+ // 클라 필터링
107128 const filteredMembers = useMemo ( ( ) => {
108129 let base = members ;
109130 if ( teamFilter ) base = base . filter ( ( m ) => m . team === teamFilter ) ;
@@ -199,129 +220,128 @@ export default function AttendancePage() {
199220 } ;
200221
201222 return ( < div className = "flex flex-col max-w-[1100px] mx-auto min-h-[100svh] py-16 px-6" >
202- < h1 className = "font-bold mb-6 text-4xl tablet:text-3xl mobile:text-2xl" > 출석 관리</ h1 >
223+ < h1 className = "font-bold mb-6 text-4xl tablet:text-3xl mobile:text-2xl" > 출석 관리</ h1 >
224+
225+ < div className = "grid grid-cols-1 md:grid-cols-3 gap-4" >
226+ { /* 날짜 */ }
227+ < Card >
228+ < CardBody className = "gap-3" >
229+ < div className = "flex items-center justify-between" >
230+ < b > 날짜</ b >
231+ < Button size = "sm" color = "primary" onPress = { addToday } >
232+ 오늘 추가
233+ </ Button >
234+ </ div >
203235
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 >
236+ < Input type = "date" value = { date } onChange = { ( e ) => setDate ( e . target . value ) } />
237+ < Divider />
238+ < div className = "max-h-[180px] overflow-auto space-y-2" >
239+ { dates . map ( ( d ) => ( < div key = { d } className = "flex items-center justify-between" >
240+ < Button size = "sm" variant = "light" onPress = { ( ) => setDate ( d ) } >
241+ { d === date ? < b > { d } </ b > : d }
242+ </ Button >
243+ < Button size = "sm" color = "danger" variant = "flat" onPress = { ( ) => removeDate ( d ) } >
244+ 삭제
245+ </ Button >
246+ </ div > ) ) }
247+ { dates . length === 0 && ( < div className = "text-sm text-foreground-500" > 등록된 날짜가 없습니다.</ div > ) }
248+ </ div >
249+ </ CardBody >
250+ </ Card >
251+
252+ { /* 필터 & 저장 */ }
253+ < Card >
254+ < CardBody className = "gap-3" >
255+ < div className = "flex items-center justify-between" >
256+ < b > 필터 / 저장</ b >
257+ < Button
258+ size = "sm"
259+ color = "primary"
260+ variant = "flat"
261+ onPress = { saveSnapshot }
262+ isDisabled = { ! dirty || ! members . length }
263+ >
264+ 저장{ dirty ? ' *' : '' }
265+ </ Button >
266+ </ div >
214267
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 }
268+ < Select
269+ label = "팀(클라이언트 필터)"
270+ selectedKeys = { teamFilter ? new Set ( [ teamFilter ] ) : new Set ( ) }
271+ onSelectionChange = { ( keys ) => {
272+ const first = String ( Array . from ( keys || [ ] ) [ 0 ] ?? '' ) ;
273+ setTeamFilter ( first || '' ) ;
274+ } }
275+ variant = "bordered"
276+ >
277+ { teamOptions . map ( ( name ) => ( < SelectItem key = { name } value = { name } >
278+ { name }
279+ </ SelectItem > ) ) }
280+ </ Select >
281+
282+ < Input placeholder = "이름 검색" value = { filter } onValueChange = { setFilter } size = "sm" />
283+
284+ < div className = "flex gap-2" >
285+ < Button size = "sm" onPress = { ( ) => checkAll ( true ) } color = "success" variant = "flat" >
286+ (필터된) 전체 체크
222287 </ Button >
223- < Button size = "sm" color = "danger" variant = "flat" onPress = { ( ) => removeDate ( d ) } >
224- 삭제
288+ < Button size = "sm" onPress = { ( ) => checkAll ( false ) } color = "warning" variant = "flat" >
289+ (필터된) 전체 해제
225290 </ Button >
226- </ div > ) ) }
227- { dates . length === 0 && ( < div className = "text-sm text-foreground-500" > 등록된 날짜가 없습니다.</ div > ) }
228- </ div >
229- </ CardBody >
230- </ Card >
231-
232- { /* 필터 & 저장 */ }
233- < Card >
291+ </ div >
292+ </ CardBody >
293+ </ Card >
294+
295+ { /* 요약 */ }
296+ < Card >
297+ < CardBody className = "gap-3" >
298+ < b > 요약</ b >
299+ { summary ? ( < div className = "text-sm" >
300+ < div className = "mb-2" > 전체 { summary . present } / { summary . total } </ div >
301+ < Divider />
302+ < div className = "mt-2 space-y-1" >
303+ { summary . perTeam . map ( ( ts ) => (
304+ < div key = { ts . teamId } className = "flex items-center justify-between" >
305+ < span > { ts . teamName } </ span >
306+ < span > { ts . present } / { ts . total } </ span >
307+ </ div > ) ) }
308+ </ div >
309+ </ div > ) : ( < div className = "text-foreground-500 text-sm" > 로딩...</ div > ) }
310+ </ CardBody >
311+ </ Card >
312+ </ div >
313+
314+ { /* 팀원 목록 */ }
315+ < Card className = "mt-6" >
234316 < CardBody className = "gap-3" >
235317 < div className = "flex items-center justify-between" >
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 >
318+ < div >
319+ < b > 팀원</ b >
320+ < div className = "text-xs text-foreground-500" >
321+ { date } · { teamFilter || '전체 팀' }
322+ </ div >
323+ </ div >
246324 </ div >
247325
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 >
271- </ div >
272- </ CardBody >
273- </ Card >
326+ < Divider />
274327
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 > ) }
328+ < div className = "max-h-[460px] overflow-auto" >
329+ { filteredMembers . map ( ( m ) => {
330+ const id = String ( m . userId ) ;
331+ const checked = presentSet . has ( id ) ;
332+ return ( < div key = { id } className = "flex items-center justify-between py-2" >
333+ < div className = "flex items-center gap-3" >
334+ < Checkbox isSelected = { checked } onValueChange = { ( ) => toggleMember ( m ) } >
335+ { m . name } { ' ' }
336+ < span className = "text-xs text-foreground-500 ml-2" > ({ m . team } )</ span >
337+ </ Checkbox >
338+ </ div >
339+ </ div > ) ;
340+ } ) }
341+ { filteredMembers . length === 0 && (
342+ < div className = "text-sm text-foreground-500 py-3" > 표시할 팀원이 없습니다.</ div > ) }
343+ </ div >
290344 </ CardBody >
291345 </ 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 > ) ;
346+ </ div > ) ;
327347}
0 commit comments