Skip to content

Commit 46c92d9

Browse files
committed
fix(core-attendance): Axios 인증/경로 버그 수정 및 출석 화면 안정화
1 parent 4375810 commit 46c92d9

File tree

2 files changed

+329
-501
lines changed

2 files changed

+329
-501
lines changed

src/app/core-attendance/page.jsx

Lines changed: 139 additions & 178 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,22 @@
11
'use client';
22

33
import {useEffect, useMemo, useState} from 'react';
4-
import axios from 'axios';
54
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') + '/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
/** ===== 유틸 ===== */
688
const ymd = (d = new Date()) => d.toISOString().slice(0, 10);
699
const getQS = (k) => typeof window !== 'undefined' ? new URL(window.location.href).searchParams.get(k) || '' : '';
7010
const 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

7717
export 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

Comments
 (0)