Skip to content

Commit 02b4b5d

Browse files
authored
Merge pull request #179 from CSE-Shaco/develop
Fix: 출석 관리 페이지 UI 개선 및 권한 가드 리디렉션 로직 수정
2 parents 67bd7e5 + b22720c commit 02b4b5d

File tree

2 files changed

+101
-47
lines changed

2 files changed

+101
-47
lines changed

src/app/core-attendance/page.jsx

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

3-
import {useEffect, useMemo, useState} from 'react';
3+
import {useEffect, useMemo, useRef, useState} from 'react';
44
import {Button, Card, CardBody, Checkbox, Divider, Input, Select, SelectItem} from '@nextui-org/react';
55
import {useAuthenticatedApi} from '@/hooks/useAuthenticatedApi';
66

@@ -15,7 +15,7 @@ const setQS = (entries) => {
1515
};
1616

1717
export default function AttendancePage() {
18-
const {apiClient} = useAuthenticatedApi(); // ✅ 인증 포함 Axios 인스턴스
18+
const {apiClient} = useAuthenticatedApi();
1919

2020
// URL state
2121
const [date, setDate] = useState(typeof window !== 'undefined' ? getQS('date') || ymd() : ymd());
@@ -28,10 +28,13 @@ export default function AttendancePage() {
2828

2929
// UI
3030
const [filter, setFilter] = useState('');
31-
const [teamFilter, setTeamFilter] = useState(''); // 팀 라벨 기준 필터 (''=전체)
31+
const [teamFilter, setTeamFilter] = useState(''); // 팀 라벨 기준 (''=전체)
3232
const [presentSet, setPresentSet] = useState(new Set()); // Set<string(userId)>
3333
const [dirty, setDirty] = useState(false);
3434

35+
// 초기 상태(서버 로드 직후 present 사용자들) → Δ 저장용
36+
const initialPresentSetRef = useRef(new Set());
37+
3538
/** ===== API 래퍼 ===== */
3639
const api = {
3740
getDates: async () => (await apiClient.get('/core-attendance/meetings')).data.data, // { dates: [...] }
@@ -68,13 +71,13 @@ export default function AttendancePage() {
6871
// eslint-disable-next-line react-hooks/exhaustive-deps
6972
}, []);
7073

71-
/** 팀 로드 (리드=본인 팀만 / 관리자=전체) */
74+
/** 팀 로드 (리드=본인 팀만 / 오거나이저·어드민=전체) */
7275
useEffect(() => {
7376
(async () => {
7477
try {
7578
const list = await api.getTeams();
7679
setTeams(Array.isArray(list) ? list : []);
77-
// 자동 선택 UX(리드 등 팀 1개만 내려오면 자동 선택)
80+
// 리드(팀 1개만 내려올 때) 자동 선택
7881
if (!teamFilter && list?.length === 1) setTeamFilter(list[0].name);
7982
} catch {
8083
setTeams([]);
@@ -93,10 +96,12 @@ export default function AttendancePage() {
9396
const init = new Set();
9497
rows.forEach((r) => r.present && init.add(String(r.userId)));
9598
setPresentSet(init);
99+
initialPresentSetRef.current = new Set(init); // 초기 상태 보관
96100
setDirty(false);
97101
} catch {
98102
setMembers([]);
99103
setPresentSet(new Set());
104+
initialPresentSetRef.current = new Set();
100105
setDirty(false);
101106
}
102107
})();
@@ -117,6 +122,9 @@ export default function AttendancePage() {
117122
// eslint-disable-next-line react-hooks/exhaustive-deps
118123
}, [date]);
119124

125+
/** “전체” 옵션은 팀이 2개 이상 전달될 때(= 오거나이저/어드민)만 노출 */
126+
const showAllOption = teams.length > 1;
127+
120128
/** 팀 옵션(라벨) */
121129
const teamOptions = useMemo(() => Array.from(new Set(teams.map((t) => t.name))).filter(Boolean), [teams]);
122130

@@ -142,6 +150,21 @@ export default function AttendancePage() {
142150
}
143151
};
144152

153+
const addSelectedDate = async () => {
154+
try {
155+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
156+
alert('날짜 형식이 올바르지 않습니다. YYYY-MM-DD');
157+
return;
158+
}
159+
await api.addDate(date);
160+
const dl = await api.getDates();
161+
setDates(dl.dates);
162+
alert(`"${date}"가 추가되었습니다.`);
163+
} catch {
164+
alert('선택 날짜 추가에 실패했습니다.');
165+
}
166+
};
167+
145168
const removeDate = async (d) => {
146169
try {
147170
await api.deleteDate(d);
@@ -176,17 +199,36 @@ export default function AttendancePage() {
176199
setDirty(true);
177200
};
178201

179-
/** 저장(스냅샷) – present=true & present=false 두 번 호출 (서버는 List<Long> 기대 → 숫자로 전송) */
202+
/** 저장(Δ만 전송) */
180203
const saveSnapshot = async () => {
204+
// 현재/초기 출석 집합
205+
const now = presentSet;
206+
const init = initialPresentSetRef.current;
207+
208+
// Δ 계산
209+
const added = []; // now ∖ init → present=true
210+
const removed = []; // init ∖ now → present=false
211+
181212
const allIdsStr = members.map((m) => String(m.userId));
182-
const presentIdsStr = allIdsStr.filter((id) => presentSet.has(id));
183-
const absentIdsStr = allIdsStr.filter((id) => !presentSet.has(id));
184-
const presentIds = presentIdsStr.map((s) => Number(s));
185-
const absentIds = absentIdsStr.map((s) => Number(s));
213+
// members에 있는 대상들만 비교 (안전)
214+
for (const id of allIdsStr) {
215+
const inNow = now.has(id);
216+
const inInit = init.has(id);
217+
if (inNow && !inInit) added.push(Number(id));
218+
if (!inNow && inInit) removed.push(Number(id));
219+
}
220+
221+
if (!added.length && !removed.length) {
222+
setDirty(false);
223+
alert('변경된 내용이 없습니다.');
224+
return;
225+
}
186226

187227
try {
188-
if (presentIds.length) await api.saveAttendance(date, presentIds, true);
189-
if (absentIds.length) await api.saveAttendance(date, absentIds, false);
228+
if (added.length) await api.saveAttendance(date, added, true);
229+
if (removed.length) await api.saveAttendance(date, removed, false);
230+
// 저장 성공 → 초기 상태 갱신
231+
initialPresentSetRef.current = new Set(presentSet);
190232
setDirty(false);
191233
await refreshSummary();
192234
alert('저장되었습니다.');
@@ -212,9 +254,11 @@ export default function AttendancePage() {
212254
<CardBody className="gap-3 text-white">
213255
<div className="flex items-center justify-between">
214256
<b>날짜</b>
215-
<Button size="sm" color="primary" onPress={addToday}>
216-
오늘 추가
217-
</Button>
257+
<div className="flex gap-2">
258+
<Button size="sm" color="primary" onPress={addToday}>오늘 추가</Button>
259+
<Button size="sm" color="secondary" variant="flat" onPress={addSelectedDate}>선택 날짜
260+
추가</Button>
261+
</div>
218262
</div>
219263

220264
<Input
@@ -258,25 +302,26 @@ export default function AttendancePage() {
258302
</Button>
259303
</div>
260304

261-
{/* 팀 선택(“전체” 포함) */}
305+
{/* 팀 선택(오거나이저/어드민만 “전체” 노출) */}
262306
<Select
263307
label="팀(클라이언트 필터)"
264-
selectedKeys={teamFilter ? new Set([teamFilter]) : new Set(['전체'])}
308+
selectedKeys={showAllOption ? (teamFilter ? new Set([teamFilter]) : new Set(['__ALL__'])) : (teamFilter ? new Set([teamFilter]) : new Set())}
265309
onSelectionChange={(keys) => {
266310
const first = String(Array.from(keys || [])[0] ?? '');
267-
setTeamFilter(first === '전체' ? '' : first);
311+
if (showAllOption && first === '__ALL__') setTeamFilter(''); else setTeamFilter(first || '');
268312
}}
269313
variant="bordered"
270314
classNames={{
271315
trigger: 'bg-default-200/50 dark:bg-default/60',
272316
label: 'text-black/50 dark:text-white/90',
273317
value: 'text-black/90 dark:text-white/90',
274318
popoverContent: 'bg-default-100 dark:bg-default-50',
319+
listbox: 'text-white',
275320
}}
276321
>
277-
<SelectItem key="전체" value="전체" className="text-white">
278-
전체
279-
</SelectItem>
322+
{showAllOption && (<SelectItem key="__ALL__" value="__ALL__" className="text-white">
323+
전체
324+
</SelectItem>)}
280325
{teamOptions.map((name) => (<SelectItem key={name} value={name} className="text-white">
281326
{name}
282327
</SelectItem>))}
@@ -335,7 +380,7 @@ export default function AttendancePage() {
335380
<div>
336381
<b>팀원</b>
337382
<div className="text-xs text-foreground-500">
338-
{date} · {teamFilter || '전체 팀'}
383+
{date} · {teamFilter || (showAllOption ? '전체 팀' : (teams[0]?.name ?? ''))}
339384
</div>
340385
</div>
341386
</div>
Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,31 @@
11
'use client';
22

3-
import { useEffect, useMemo, useRef, useState } from 'react';
4-
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
5-
import { useAuthenticatedApi } from '@/hooks/useAuthenticatedApi';
3+
import {useEffect, useMemo, useRef, useState} from 'react';
4+
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
5+
import {useAuthenticatedApi} from '@/hooks/useAuthenticatedApi';
66
import Loader from '@/components/ui/common/Loader';
77

8-
/**
9-
* ApiCodeGuard
10-
* - /auth/{role}?next=<...> 를 호출해 200(또는 body.code=200)이면 통과
11-
* - 아니면 로그인(/auth/signin?next=...)으로 보냄
12-
*
13-
* props:
14-
* - requiredRole: 'GUEST'|'MEMBER'|'CORE'|'LEAD'|'ORGANIZER'|'ADMIN' (백엔드 enum과 동일 문자열)
15-
* - nextOverride?: string // 지정 시 이 URL을 next로 사용, 없으면 현재 경로 기준 자동 계산
16-
* - children: ReactNode
17-
*/
18-
export default function ApiCodeGuard({ requiredRole, nextOverride, children }) {
8+
export default function ApiCodeGuard({requiredRole, nextOverride, children}) {
199
const router = useRouter();
2010
const pathname = usePathname();
2111
const searchParams = useSearchParams();
22-
const { apiClient } = useAuthenticatedApi();
12+
const {apiClient} = useAuthenticatedApi();
2313

2414
const [checking, setChecking] = useState(true);
2515
const [allowed, setAllowed] = useState(false);
2616

27-
// next URL 계산 (override > 현재 경로)
17+
// 로그인 실패 시 넘길 원래 목적지
2818
const nextUrl = useMemo(() => {
2919
if (nextOverride) return encodeURIComponent(nextOverride);
3020
const q = searchParams?.toString();
3121
return encodeURIComponent(`${pathname}${q ? `?${q}` : ''}`);
3222
}, [nextOverride, pathname, searchParams]);
3323

3424
const cancelledRef = useRef(false);
25+
const alertedRef = useRef(false); // 403 alert 중복 방지
3526

3627
useEffect(() => {
3728
if (!requiredRole) {
38-
// 역할이 없으면 바로 차단
3929
router.replace(`/auth/signin?next=${nextUrl}`);
4030
return;
4131
}
@@ -44,21 +34,40 @@ export default function ApiCodeGuard({ requiredRole, nextOverride, children }) {
4434

4535
const verify = async () => {
4636
try {
47-
// ✅ 권한 체크: /auth/{role}?next=...
4837
const res = await apiClient.get(`/auth/${requiredRole}`, {
49-
params: { next: decodeURIComponent(nextUrl) }, // 서버가 raw URL 원하면 decode해서 전달
38+
headers: {
39+
Accept: 'application/json', 'X-Auth-Probe': '1',
40+
}, validateStatus: (s) => s === 200 || s === 204 || s === 401 || s === 403,
5041
});
5142

5243
if (cancelledRef.current) return;
5344

54-
const okHttp = res?.status === 200 || res?.status === 204;
55-
const okBody = (res?.data?.code ?? 200) === 200;
56-
57-
if (okHttp && okBody) {
45+
// 성공(권한 충족)
46+
if (res.status === 200 || res.status === 204 || (res?.data?.code ?? 200) === 200) {
5847
setAllowed(true);
59-
} else {
48+
return;
49+
}
50+
51+
// 인증 필요
52+
if (res.status === 401) {
6053
router.replace(`/auth/signin?next=${nextUrl}`);
54+
return;
6155
}
56+
57+
// 권한 부족
58+
if (res.status === 403) {
59+
if (!alertedRef.current) {
60+
alertedRef.current = true;
61+
// eslint-disable-next-line no-alert
62+
alert('권한이 부족합니다.');
63+
}
64+
// 안전한 경로로 이동 (필요 시 원하는 경로로 변경)
65+
router.replace('/');
66+
return;
67+
}
68+
69+
// 기타는 로그인으로 유도
70+
router.replace(`/auth/signin?next=${nextUrl}`);
6271
} catch {
6372
if (!cancelledRef.current) {
6473
router.replace(`/auth/signin?next=${nextUrl}`);
@@ -74,7 +83,7 @@ export default function ApiCodeGuard({ requiredRole, nextOverride, children }) {
7483
};
7584
}, [apiClient, requiredRole, nextUrl, router]);
7685

77-
if (checking) return <Loader isLoading />;
86+
if (checking) return <Loader isLoading/>;
7887
if (!allowed) return null;
7988
return <>{children}</>;
8089
}

0 commit comments

Comments
 (0)