Skip to content

Commit 9eccb95

Browse files
authored
Merge pull request #164 from CSE-Shaco/develop
feat(core-attendance): 출석 관리 페이지 추가
2 parents 73824a2 + d88df88 commit 9eccb95

File tree

2 files changed

+383
-0
lines changed

2 files changed

+383
-0
lines changed

src/app/core_attendance/layout.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Suspense } from "react";
2+
import Loader from "@/components/ui/common/Loader.jsx";
3+
4+
export const metadata = {
5+
title: "Core Attendance",
6+
description: "GDGoC INHA Core Attendance Management",
7+
};
8+
9+
export default function CoreAttendanceLayout({ children }) {
10+
return <Suspense fallback={<Loader />}>{children}</Suspense>;
11+
}

src/app/core_attendance/page.jsx

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

Comments
 (0)