Skip to content

Commit 02394dc

Browse files
authored
Merge pull request #169 from CSE-Shaco/develop
refactor(frontend): migrate route to /core-attendance and clean up files
2 parents 1450d33 + 7c5acf6 commit 02394dc

File tree

3 files changed

+355
-358
lines changed

3 files changed

+355
-358
lines changed

src/app/core-attendance/page.jsx

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,355 @@
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

Comments
 (0)