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+ // 액세스 토큰 부착 & 401→refresh 재시도(간단 버전)
15+ const getAccessToken = ( ) => localStorage . getItem ( 'access_token' ) ;
16+ API . interceptors . request . use ( ( config ) => {
17+ const t = getAccessToken ( ) ;
18+ if ( t ) {
19+ config . headers = config . headers || { } ;
20+ config . headers . Authorization = `Bearer ${ t } ` ;
21+ }
22+ return config ;
23+ } ) ;
24+ API . interceptors . response . use ( ( r ) => r , async ( err ) => {
25+ const original = err . config || { } ;
26+ if ( err . response ?. status === 401 && ! original . __retry ) {
27+ original . __retry = true ;
28+ const base = process . env . NEXT_PUBLIC_BASE_API_URL ?. replace ( / \/ $ / , '' ) || 'http://localhost:8080' ;
29+ try {
30+ const res = await axios . post ( `${ base } /api/v1/auth/refresh` , null , { withCredentials : true } ) ;
31+ const newAccess = res . data ?. data ?. accessToken ;
32+ if ( newAccess ) {
33+ localStorage . setItem ( 'access_token' , newAccess ) ;
34+ original . headers = original . headers || { } ;
35+ original . headers . Authorization = `Bearer ${ newAccess } ` ;
36+ return API . request ( original ) ;
37+ }
38+ } catch ( _ ) {
39+ localStorage . removeItem ( 'access_token' ) ;
40+ }
41+ }
42+ return Promise . reject ( err ) ;
43+ } ) ;
44+
45+ const 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+
51+ // Members (전체 팀 포함)
52+ getMembers : async ( date ) => ( await API . get ( `/${ date } /members` ) ) . data . data , // [{ userId,name,team,present,... }]
53+
54+ // Batch save
55+ saveAttendance : async ( date , userIds , present ) => ( await API . put ( `/${ date } /attendance` , {
56+ userIds, present
57+ } ) ) . data . data ,
58+
59+ // Summary(옵션)
60+ summary : async ( date ) => ( await API . get ( `/${ date } /summary` ) ) . data . data ,
61+ } ;
62+
63+ /** ===== 유틸 ===== */
64+ const ymd = ( d = new Date ( ) ) => d . toISOString ( ) . slice ( 0 , 10 ) ;
65+ const getQS = ( k ) => typeof window !== 'undefined' ? new URL ( window . location . href ) . searchParams . get ( k ) || '' : '' ;
66+ const setQS = ( entries ) => {
67+ if ( typeof window === 'undefined' ) return ;
68+ const u = new URL ( window . location . href ) ;
69+ Object . entries ( entries ) . forEach ( ( [ k , v ] ) => ( v ? u . searchParams . set ( k , v ) : u . searchParams . delete ( k ) ) ) ;
70+ window . history . replaceState ( { } , '' , u . toString ( ) ) ;
71+ } ;
72+
73+ export default function AttendancePage ( ) {
74+ // URL state
75+ const [ date , setDate ] = useState ( typeof window !== 'undefined' ? getQS ( 'date' ) || ymd ( ) : ymd ( ) ) ;
76+
77+ // data
78+ const [ dates , setDates ] = useState ( [ ] ) ;
79+ const [ members , setMembers ] = useState ( [ ] ) ; // [{userId,name,team,present,...}]
80+ const [ summary , setSummary ] = useState ( null ) ;
81+
82+ // UI
83+ const [ filter , setFilter ] = useState ( '' ) ;
84+ const [ teamFilter , setTeamFilter ] = useState ( '' ) ; // 클라 사이드 팀 필터
85+ const [ presentSet , setPresentSet ] = useState ( new Set ( ) ) ;
86+ const [ dirty , setDirty ] = useState ( false ) ;
87+
88+ /** URL 동기화 */
89+ useEffect ( ( ) => {
90+ setQS ( { date} ) ;
91+ } , [ date ] ) ;
92+
93+ /** 날짜 로드 */
94+ useEffect ( ( ) => {
95+ ( async ( ) => {
96+ try {
97+ const dl = await api . getDates ( ) ; // { dates: [...] }
98+ setDates ( dl . dates ) ;
99+ if ( ! dl . dates . includes ( date ) && dl . dates . length > 0 ) setDate ( dl . dates [ 0 ] ) ;
100+ } catch {
101+ alert ( '날짜 목록을 불러오지 못했습니다.' ) ;
102+ }
103+ } ) ( ) ;
104+ // eslint-disable-next-line react-hooks/exhaustive-deps
105+ } , [ ] ) ;
106+
107+ /** 선택 날짜 → 멤버/출석 로드 */
108+ useEffect ( ( ) => {
109+ ( async ( ) => {
110+ if ( ! date ) return ;
111+ try {
112+ const rows = await api . getMembers ( date ) ;
113+ setMembers ( rows ) ;
114+ const init = new Set ( ) ;
115+ rows . forEach ( ( r ) => r . present && init . add ( String ( r . userId ) ) ) ;
116+ setPresentSet ( init ) ;
117+ setDirty ( false ) ;
118+ } catch {
119+ setMembers ( [ ] ) ;
120+ setPresentSet ( new Set ( ) ) ;
121+ setDirty ( false ) ;
122+ }
123+ } ) ( ) ;
124+ } , [ date ] ) ;
125+
126+ /** 요약 로드(옵션) */
127+ useEffect ( ( ) => {
128+ ( async ( ) => {
129+ if ( ! date ) return ;
130+ try {
131+ const s = await api . summary ( date ) ;
132+ setSummary ( s ) ;
133+ } catch {
134+ setSummary ( null ) ;
135+ }
136+ } ) ( ) ;
137+ } , [ date ] ) ;
138+
139+ // 클라 필터링
140+ const teamOptions = useMemo ( ( ) => Array . from ( new Set ( members . map ( ( m ) => m . team ) ) ) . filter ( Boolean ) , [ members ] ) ;
141+
142+ const filteredMembers = useMemo ( ( ) => {
143+ let base = members ;
144+ if ( teamFilter ) base = base . filter ( ( m ) => m . team === teamFilter ) ;
145+ const q = filter . trim ( ) ;
146+ if ( ! q ) return base ;
147+ return base . filter ( ( m ) => m . name . includes ( q ) ) ;
148+ } , [ members , filter , teamFilter ] ) ;
149+
150+ /** 날짜 조작 */
151+ const addToday = async ( ) => {
152+ try {
153+ const d = ymd ( ) ;
154+ await api . addDate ( d ) ;
155+ const dl = await api . getDates ( ) ;
156+ setDates ( dl . dates ) ;
157+ setDate ( d ) ;
158+ } catch {
159+ alert ( '날짜 추가에 실패했습니다.' ) ;
160+ }
161+ } ;
162+
163+ const removeDate = async ( d ) => {
164+ try {
165+ await api . deleteDate ( d ) ;
166+ const dl = await api . getDates ( ) ;
167+ setDates ( dl . dates ) ;
168+ if ( d === date ) setDate ( dl . dates [ 0 ] ?? ymd ( ) ) ;
169+ } catch {
170+ alert ( '날짜 삭제에 실패했습니다.' ) ;
171+ }
172+ } ;
173+
174+ /** 개별 토글(낙관적) */
175+ const toggleMember = async ( m ) => {
176+ const id = String ( m . userId ) ;
177+ const next = ! presentSet . has ( id ) ;
178+
179+ setPresentSet ( ( prev ) => {
180+ const n = new Set ( prev ) ;
181+ next ? n . add ( id ) : n . delete ( id ) ;
182+ return n ;
183+ } ) ;
184+ setDirty ( true ) ;
185+
186+ try {
187+ await api . saveAttendance ( date , [ id ] , next ) ;
188+ await refreshSummary ( ) ;
189+ } catch {
190+ alert ( '출석 변경에 실패했습니다.' ) ;
191+ // 롤백
192+ setPresentSet ( ( prev ) => {
193+ const n = new Set ( prev ) ;
194+ next ? n . delete ( id ) : n . add ( id ) ;
195+ return n ;
196+ } ) ;
197+ }
198+ } ;
199+
200+ /** 전체 체크/해제(로컬) */
201+ const checkAll = ( value ) => {
202+ const baseIds = filteredMembers . map ( ( m ) => String ( m . userId ) ) ; // 현재 필터된 목록 기준
203+ setPresentSet ( ( prev ) => {
204+ const n = new Set ( prev ) ;
205+ if ( value ) baseIds . forEach ( ( id ) => n . add ( id ) ) ; else baseIds . forEach ( ( id ) => n . delete ( id ) ) ;
206+ return n ;
207+ } ) ;
208+ setDirty ( true ) ;
209+ } ;
210+
211+ /** 저장(스냅샷) – present=true & present=false 두 번 호출 */
212+ const saveSnapshot = async ( ) => {
213+ const allIds = members . map ( ( m ) => String ( m . userId ) ) ;
214+ const presentIds = allIds . filter ( ( id ) => presentSet . has ( id ) ) ;
215+ const absentIds = allIds . filter ( ( id ) => ! presentSet . has ( id ) ) ;
216+
217+ try {
218+ if ( presentIds . length ) await api . saveAttendance ( date , presentIds , true ) ;
219+ if ( absentIds . length ) await api . saveAttendance ( date , absentIds , false ) ;
220+ setDirty ( false ) ;
221+ await refreshSummary ( ) ;
222+ alert ( '저장되었습니다.' ) ;
223+ } catch {
224+ alert ( '저장 중 오류가 발생했습니다.' ) ;
225+ }
226+ } ;
227+
228+ const refreshSummary = async ( ) => {
229+ try {
230+ setSummary ( await api . summary ( date ) ) ;
231+ } catch {
232+ setSummary ( null ) ;
233+ }
234+ } ;
235+
236+ 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 >
238+
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 >
249+
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 }
257+ </ Button >
258+ < Button size = "sm" color = "danger" variant = "flat" onPress = { ( ) => removeDate ( d ) } >
259+ 삭제
260+ </ 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 >
269+ < CardBody className = "gap-3" >
270+ < 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 >
276+ </ div >
277+
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 >
304+
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 > ) }
320+ </ CardBody >
321+ </ 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 > ) ;
355+ }
0 commit comments