1+ 'use client' ;
2+
3+ import { useEffect , useMemo , useState } from 'react' ;
4+ import axios from 'axios' ;
5+ import { 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' ) + '/api/v1/core-attendance/meetings' ,
10+ timeout : 15000 ,
11+ withCredentials : true ,
12+ } ) ;
13+
14+ const api = {
15+ // Dates
16+ getDates : async ( ) => ( await API . get ( `/` ) ) . data . data , // { dates: [...] }
17+ addDate : async ( date ) => ( await API . post ( `/` , { date} ) ) . data . data ,
18+ deleteDate : async ( date ) => ( await API . delete ( `/${ date } ` ) ) . data . data ,
19+
20+ // Teams
21+ getTeams : async ( ) => ( await API . get ( `/teams` ) ) . data . data ,
22+
23+ // Members with presence
24+ getMembers : async ( date , teamId ) => ( await API . get ( `/${ date } /members` , { params : teamId ? { team : teamId } : { } } ) ) . data . data ,
25+
26+ // Batch save attendance
27+ saveAttendance : async ( date , userIds , present , teamId ) => ( await API . put ( `/${ date } /attendance` , {
28+ userIds,
29+ present
30+ } , { params : teamId ? { team : teamId } : { } } ) ) . data . data ,
31+
32+ // Summary
33+ summary : async ( date , teamId ) => ( await API . get ( `/${ date } /summary` , { params : teamId ? { team : teamId } : { } } ) ) . data . data ,
34+ } ;
35+
36+ /** ===== 유틸 ===== */
37+ const ymd = ( d = new Date ( ) ) => d . toISOString ( ) . slice ( 0 , 10 ) ;
38+ const getQS = ( k ) => typeof window !== 'undefined' ? new URL ( window . location . href ) . searchParams . get ( k ) || '' : '' ;
39+ const setQS = ( entries ) => {
40+ if ( typeof window === 'undefined' ) return ;
41+ const u = new URL ( window . location . href ) ;
42+ Object . entries ( entries ) . forEach ( ( [ k , v ] ) => ( v ? u . searchParams . set ( k , v ) : u . searchParams . delete ( k ) ) ) ;
43+ window . history . replaceState ( { } , '' , u . toString ( ) ) ;
44+ } ;
45+
46+ export default function AttendancePage ( ) {
47+ // URL state
48+ const [ date , setDate ] = useState ( typeof window !== 'undefined' ? getQS ( 'date' ) || ymd ( ) : ymd ( ) ) ;
49+ const [ teamId , setTeamId ] = useState ( typeof window !== 'undefined' ? getQS ( 'teamId' ) : '' ) ;
50+
51+ // data
52+ const [ dates , setDates ] = useState ( [ ] ) ;
53+ const [ teams , setTeams ] = useState ( [ ] ) ;
54+ const [ members , setMembers ] = useState ( [ ] ) ;
55+ const [ summary , setSummary ] = useState ( null ) ;
56+ const [ filter , setFilter ] = useState ( '' ) ;
57+
58+ // UI
59+ const [ presentSet , setPresentSet ] = useState ( new Set ( ) ) ;
60+ const [ dirty , setDirty ] = useState ( false ) ;
61+
62+ const selectedTeam = useMemo ( ( ) => teams . find ( ( t ) => t . id === teamId ) ?? teams [ 0 ] , [ teams , teamId ] ) ;
63+
64+ const filteredMembers = useMemo ( ( ) => {
65+ const base = members ;
66+ const q = filter . trim ( ) ;
67+ if ( ! q ) return base ;
68+ return base . filter ( ( m ) => m . name . includes ( q ) ) ;
69+ } , [ members , filter ] ) ;
70+
71+ /** URL 동기화 */
72+ useEffect ( ( ) => {
73+ setQS ( { date, teamId : teamId || undefined } ) ;
74+ } , [ date , teamId ] ) ;
75+
76+ /** 날짜 로드 */
77+ useEffect ( ( ) => {
78+ ( async ( ) => {
79+ try {
80+ const dl = await api . getDates ( ) ; // { dates: [...] }
81+ setDates ( dl . dates ) ;
82+ if ( ! dl . dates . includes ( date ) && dl . dates . length > 0 ) setDate ( dl . dates [ 0 ] ) ;
83+ } catch {
84+ alert ( '날짜 목록을 불러오지 못했습니다.' ) ;
85+ }
86+ } ) ( ) ;
87+ // eslint-disable-next-line react-hooks/exhaustive-deps
88+ } , [ ] ) ;
89+
90+ /** 팀 로드 */
91+ useEffect ( ( ) => {
92+ ( async ( ) => {
93+ try {
94+ const list = await api . getTeams ( ) ;
95+ setTeams ( list ) ;
96+ if ( list . length && ! list . find ( ( t ) => t . id === teamId ) ) setTeamId ( list [ 0 ] . id ) ;
97+ } catch {
98+ alert ( '팀 목록을 불러오지 못했습니다.' ) ;
99+ }
100+ } ) ( ) ;
101+ // eslint-disable-next-line react-hooks/exhaustive-deps
102+ } , [ ] ) ;
103+
104+ /** 특정 날짜의 팀원 + 출석 로드 */
105+ useEffect ( ( ) => {
106+ ( async ( ) => {
107+ if ( ! date || ! selectedTeam ) return ;
108+ try {
109+ const rows = await api . getMembers ( date , selectedTeam . id ) ;
110+ setMembers ( rows ) ;
111+ const init = new Set ( ) ;
112+ rows . forEach ( ( r ) => r . present && init . add ( String ( r . userId ) ) ) ;
113+ setPresentSet ( init ) ;
114+ setDirty ( false ) ;
115+ } catch {
116+ setMembers ( [ ] ) ;
117+ setPresentSet ( new Set ( ) ) ;
118+ setDirty ( false ) ;
119+ }
120+ } ) ( ) ;
121+ } , [ date , selectedTeam ?. id ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
122+
123+ /** 요약 로드 */
124+ useEffect ( ( ) => {
125+ ( async ( ) => {
126+ if ( ! date ) return ;
127+ try {
128+ const s = await api . summary ( date , selectedTeam ?. id ) ;
129+ setSummary ( s ) ;
130+ } catch {
131+ setSummary ( null ) ;
132+ }
133+ } ) ( ) ;
134+ } , [ date , selectedTeam ?. id ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
135+
136+ /** 날짜 조작 */
137+ const addToday = async ( ) => {
138+ try {
139+ const d = ymd ( ) ;
140+ await api . addDate ( d ) ;
141+ const dl = await api . getDates ( ) ;
142+ setDates ( dl . dates ) ;
143+ setDate ( d ) ;
144+ } catch {
145+ alert ( '날짜 추가에 실패했습니다.' ) ;
146+ }
147+ } ;
148+
149+ const removeDate = async ( d ) => {
150+ try {
151+ await api . deleteDate ( d ) ;
152+ const dl = await api . getDates ( ) ;
153+ setDates ( dl . dates ) ;
154+ if ( d === date ) setDate ( dl . dates [ 0 ] ?? ymd ( ) ) ;
155+ } catch {
156+ alert ( '날짜 삭제에 실패했습니다.' ) ;
157+ }
158+ } ;
159+
160+ /** 개별 토글: 배치 API로 “단건” 반영 */
161+ const toggleMember = async ( m ) => {
162+ if ( ! selectedTeam ) return ;
163+ const id = String ( m . id ?? m . userId ) ;
164+ const next = ! presentSet . has ( id ) ;
165+
166+ // UI 낙관적 업데이트
167+ setPresentSet ( ( prev ) => {
168+ const n = new Set ( prev ) ;
169+ next ? n . add ( id ) : n . delete ( id ) ;
170+ return n ;
171+ } ) ;
172+ setDirty ( true ) ;
173+
174+ try {
175+ await api . saveAttendance ( date , [ id ] , next , selectedTeam . id ) ;
176+ await refreshSummary ( ) ;
177+ } catch {
178+ alert ( '출석 변경에 실패했습니다.' ) ;
179+ // 롤백
180+ setPresentSet ( ( prev ) => {
181+ const n = new Set ( prev ) ;
182+ next ? n . delete ( id ) : n . add ( id ) ;
183+ return n ;
184+ } ) ;
185+ }
186+ } ;
187+
188+ /** 전체 체크/해제(로컬) */
189+ const checkAll = ( value ) => {
190+ if ( ! selectedTeam ) return ;
191+ if ( value ) {
192+ setPresentSet ( new Set ( members . map ( ( m ) => String ( m . userId ) ) ) ) ;
193+ } else {
194+ setPresentSet ( new Set ( ) ) ;
195+ }
196+ setDirty ( true ) ;
197+ } ;
198+
199+ /** 저장(스냅샷) – present=true & present=false 두 번 호출 */
200+ const saveSnapshot = async ( ) => {
201+ if ( ! selectedTeam ) return ;
202+ const allIds = members . map ( ( m ) => String ( m . userId ) ) ;
203+ const presentIds = allIds . filter ( ( id ) => presentSet . has ( id ) ) ;
204+ const absentIds = allIds . filter ( ( id ) => ! presentSet . has ( id ) ) ;
205+
206+ try {
207+ if ( presentIds . length ) {
208+ await api . saveAttendance ( date , presentIds , true , selectedTeam . id ) ;
209+ }
210+ if ( absentIds . length ) {
211+ await api . saveAttendance ( date , absentIds , false , selectedTeam . id ) ;
212+ }
213+ setDirty ( false ) ;
214+ await refreshSummary ( ) ;
215+ alert ( '저장되었습니다.' ) ;
216+ } catch {
217+ alert ( '저장 중 오류가 발생했습니다.' ) ;
218+ }
219+ } ;
220+
221+ const refreshSummary = async ( ) => {
222+ try {
223+ setSummary ( await api . summary ( date , selectedTeam ?. id ) ) ;
224+ } catch {
225+ setSummary ( null ) ;
226+ }
227+ } ;
228+
229+ return ( < div className = "flex flex-col max-w-[1100px] mx-auto min-h-[100svh] py-16 px-6" >
230+ < h1 className = "font-bold mb-6 text-4xl tablet:text-3xl mobile:text-2xl" > 출석 관리</ h1 >
231+
232+ < div className = "grid grid-cols-1 md:grid-cols-3 gap-4" >
233+ { /* 날짜 */ }
234+ < Card >
235+ < CardBody className = "gap-3" >
236+ < div className = "flex items-center justify-between" >
237+ < b > 날짜</ b >
238+ < Button size = "sm" color = "primary" onPress = { addToday } >
239+ 오늘 추가
240+ </ Button >
241+ </ div >
242+ < Input type = "date" value = { date } onChange = { ( e ) => setDate ( e . target . value ) } />
243+ < Divider />
244+ < div className = "max-h-[180px] overflow-auto space-y-2" >
245+ { dates . map ( ( d ) => ( < div key = { d } className = "flex items-center justify-between" >
246+ < Button size = "sm" variant = "light" onPress = { ( ) => setDate ( d ) } >
247+ { d === date ? < b > { d } </ b > : d }
248+ </ Button >
249+ < Button size = "sm" color = "danger" variant = "flat" onPress = { ( ) => removeDate ( d ) } >
250+ 삭제
251+ </ Button >
252+ </ div > ) ) }
253+ { dates . length === 0 && ( < div className = "text-sm text-foreground-500" > 등록된 날짜가 없습니다.</ div > ) }
254+ </ div >
255+ </ CardBody >
256+ </ Card >
257+
258+ { /* 팀 선택 */ }
259+ < Card >
260+ < CardBody className = "gap-3" >
261+ < div className = "flex items-center justify-between" >
262+ < b > 팀 선택</ b >
263+ < Button
264+ size = "sm"
265+ color = "primary"
266+ variant = "flat"
267+ onPress = { saveSnapshot }
268+ isDisabled = { ! dirty || ! members . length }
269+ >
270+ 저장{ dirty ? ' *' : '' }
271+ </ Button >
272+ </ div >
273+
274+ < Select
275+ label = "팀"
276+ selectedKeys = { selectedTeam ?. id ? new Set ( [ selectedTeam . id ] ) : new Set ( ) }
277+ onSelectionChange = { ( keys ) => {
278+ const first = Array . from ( keys || [ ] ) [ 0 ] ?? '' ;
279+ setTeamId ( String ( first ) ) ;
280+ setQS ( { teamId : first ? String ( first ) : undefined } ) ;
281+ } }
282+ variant = "bordered"
283+ >
284+ { teams . map ( ( t ) => ( < SelectItem key = { t . id } value = { t . id } >
285+ { t . name } { t . lead ? `(리드: ${ t . lead } )` : '' }
286+ </ SelectItem > ) ) }
287+ </ Select >
288+
289+ < div className = "flex gap-2" >
290+ < Button size = "sm" onPress = { ( ) => checkAll ( true ) } color = "success" variant = "flat" >
291+ 전체 체크
292+ </ Button >
293+ < Button size = "sm" onPress = { ( ) => checkAll ( false ) } color = "warning" variant = "flat" >
294+ 전체 해제
295+ </ Button >
296+ </ div >
297+ </ CardBody >
298+ </ Card >
299+
300+ { /* 요약 */ }
301+ < Card >
302+ < CardBody className = "gap-3" >
303+ < b > 요약</ b >
304+ { summary ? ( < div className = "text-sm" >
305+ < div className = "mb-2" >
306+ 전체 { summary . present } / { summary . total }
307+ </ div >
308+ < Divider />
309+ < div className = "mt-2 space-y-1" >
310+ { summary . perTeam . map ( ( ts ) => (
311+ < div key = { ts . teamId } className = "flex items-center justify-between" >
312+ < span > { ts . teamName } </ span >
313+ < span >
314+ { ts . present } / { ts . total }
315+ </ span >
316+ </ div > ) ) }
317+ </ div >
318+ </ div > ) : ( < div className = "text-foreground-500 text-sm" > 로딩...</ div > ) }
319+ </ CardBody >
320+ </ Card >
321+ </ div >
322+
323+ { /* 팀원 목록 */ }
324+ < Card className = "mt-6" >
325+ < CardBody className = "gap-3" >
326+ < div className = "flex items-center justify-between" >
327+ < div >
328+ < b > 팀원</ b >
329+ < div className = "text-xs text-foreground-500" >
330+ { date } · { selectedTeam ?. name ?? '-' }
331+ </ div >
332+ </ div >
333+ < div className = "flex gap-2" >
334+ < Input placeholder = "팀원 검색" value = { filter } onValueChange = { setFilter } size = "sm" />
335+ </ div >
336+ </ div >
337+
338+ < Divider />
339+
340+ < div className = "max-h-[420px] overflow-auto" >
341+ { filteredMembers . map ( ( m ) => {
342+ const id = String ( m . userId ) ;
343+ const checked = presentSet . has ( id ) ;
344+ return ( < div key = { id } className = "flex items-center justify-between py-2" >
345+ < div className = "flex items-center gap-3" >
346+ < Checkbox isSelected = { checked } onValueChange = { ( ) => toggleMember ( { userId : id } ) } >
347+ { m . name }
348+ </ Checkbox >
349+ </ div >
350+ </ div > ) ;
351+ } ) }
352+ { filteredMembers . length === 0 && (
353+ < div className = "text-sm text-foreground-500 py-3" > 팀원이 없습니다.</ div > ) }
354+ </ div >
355+ </ CardBody >
356+ </ Card >
357+ </ div > ) ;
358+ }
0 commit comments