Skip to content

Commit 92863dd

Browse files
authored
Merge pull request #166 from GDGoCINHA/develop
Merge branch 'develop'
2 parents 4e4b766 + da22ef3 commit 92863dd

2 files changed

Lines changed: 369 additions & 0 deletions

File tree

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

0 commit comments

Comments
 (0)