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