diff --git a/src/app/core_attendance/layout.js b/src/app/core_attendance/layout.js new file mode 100644 index 0000000..0315c1b --- /dev/null +++ b/src/app/core_attendance/layout.js @@ -0,0 +1,11 @@ +import { Suspense } from "react"; +import Loader from "@/components/ui/common/Loader.jsx"; + +export const metadata = { + title: "Core Attendance", + description: "GDGoC INHA Core Attendance Management", +}; + +export default function CoreAttendanceLayout({ children }) { + return }>{children}; +} \ No newline at end of file diff --git a/src/app/core_attendance/page.jsx b/src/app/core_attendance/page.jsx new file mode 100644 index 0000000..6640bcf --- /dev/null +++ b/src/app/core_attendance/page.jsx @@ -0,0 +1,358 @@ +'use client'; + +import {useEffect, useMemo, useState} from 'react'; +import axios from 'axios'; +import {Button, Card, CardBody, Checkbox, Divider, Input, Select, SelectItem} from '@nextui-org/react'; + +/** ===== API 클라이언트 ===== */ +const API = axios.create({ + baseURL: (process.env.NEXT_PUBLIC_BASE_API_URL?.replace(/\/$/, '') || 'http://localhost:8080') + '/api/v1/core-attendance/meetings', + timeout: 15000, + withCredentials: true, +}); + +const api = { + // Dates + getDates: async () => (await API.get(`/`)).data.data, // { dates: [...] } + addDate: async (date) => (await API.post(`/`, {date})).data.data, + deleteDate: async (date) => (await API.delete(`/${date}`)).data.data, + + // Teams + getTeams: async () => (await API.get(`/teams`)).data.data, + + // Members with presence + getMembers: async (date, teamId) => (await API.get(`/${date}/members`, {params: teamId ? {team: teamId} : {}})).data.data, + + // Batch save attendance + saveAttendance: async (date, userIds, present, teamId) => (await API.put(`/${date}/attendance`, { + userIds, + present + }, {params: teamId ? {team: teamId} : {}})).data.data, + + // Summary + summary: async (date, teamId) => (await API.get(`/${date}/summary`, {params: teamId ? {team: teamId} : {}})).data.data, +}; + +/** ===== 유틸 ===== */ +const ymd = (d = new Date()) => d.toISOString().slice(0, 10); +const getQS = (k) => typeof window !== 'undefined' ? new URL(window.location.href).searchParams.get(k) || '' : ''; +const setQS = (entries) => { + if (typeof window === 'undefined') return; + const u = new URL(window.location.href); + Object.entries(entries).forEach(([k, v]) => (v ? u.searchParams.set(k, v) : u.searchParams.delete(k))); + window.history.replaceState({}, '', u.toString()); +}; + +export default function AttendancePage() { + // URL state + const [date, setDate] = useState(typeof window !== 'undefined' ? getQS('date') || ymd() : ymd()); + const [teamId, setTeamId] = useState(typeof window !== 'undefined' ? getQS('teamId') : ''); + + // data + const [dates, setDates] = useState([]); + const [teams, setTeams] = useState([]); + const [members, setMembers] = useState([]); + const [summary, setSummary] = useState(null); + const [filter, setFilter] = useState(''); + + // UI + const [presentSet, setPresentSet] = useState(new Set()); + const [dirty, setDirty] = useState(false); + + const selectedTeam = useMemo(() => teams.find((t) => t.id === teamId) ?? teams[0], [teams, teamId]); + + const filteredMembers = useMemo(() => { + const base = members; + const q = filter.trim(); + if (!q) return base; + return base.filter((m) => m.name.includes(q)); + }, [members, filter]); + + /** URL 동기화 */ + useEffect(() => { + setQS({date, teamId: teamId || undefined}); + }, [date, teamId]); + + /** 날짜 로드 */ + useEffect(() => { + (async () => { + try { + const dl = await api.getDates(); // { dates: [...] } + setDates(dl.dates); + if (!dl.dates.includes(date) && dl.dates.length > 0) setDate(dl.dates[0]); + } catch { + alert('날짜 목록을 불러오지 못했습니다.'); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /** 팀 로드 */ + useEffect(() => { + (async () => { + try { + const list = await api.getTeams(); + setTeams(list); + if (list.length && !list.find((t) => t.id === teamId)) setTeamId(list[0].id); + } catch { + alert('팀 목록을 불러오지 못했습니다.'); + } + })(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + /** 특정 날짜의 팀원 + 출석 로드 */ + useEffect(() => { + (async () => { + if (!date || !selectedTeam) return; + try { + const rows = await api.getMembers(date, selectedTeam.id); + setMembers(rows); + const init = new Set(); + rows.forEach((r) => r.present && init.add(String(r.userId))); + setPresentSet(init); + setDirty(false); + } catch { + setMembers([]); + setPresentSet(new Set()); + setDirty(false); + } + })(); + }, [date, selectedTeam?.id]); // eslint-disable-line react-hooks/exhaustive-deps + + /** 요약 로드 */ + useEffect(() => { + (async () => { + if (!date) return; + try { + const s = await api.summary(date, selectedTeam?.id); + setSummary(s); + } catch { + setSummary(null); + } + })(); + }, [date, selectedTeam?.id]); // eslint-disable-line react-hooks/exhaustive-deps + + /** 날짜 조작 */ + const addToday = async () => { + try { + const d = ymd(); + await api.addDate(d); + const dl = await api.getDates(); + setDates(dl.dates); + setDate(d); + } catch { + alert('날짜 추가에 실패했습니다.'); + } + }; + + const removeDate = async (d) => { + try { + await api.deleteDate(d); + const dl = await api.getDates(); + setDates(dl.dates); + if (d === date) setDate(dl.dates[0] ?? ymd()); + } catch { + alert('날짜 삭제에 실패했습니다.'); + } + }; + + /** 개별 토글: 배치 API로 “단건” 반영 */ + const toggleMember = async (m) => { + if (!selectedTeam) return; + const id = String(m.id ?? m.userId); + const next = !presentSet.has(id); + + // UI 낙관적 업데이트 + setPresentSet((prev) => { + const n = new Set(prev); + next ? n.add(id) : n.delete(id); + return n; + }); + setDirty(true); + + try { + await api.saveAttendance(date, [id], next, selectedTeam.id); + await refreshSummary(); + } catch { + alert('출석 변경에 실패했습니다.'); + // 롤백 + setPresentSet((prev) => { + const n = new Set(prev); + next ? n.delete(id) : n.add(id); + return n; + }); + } + }; + + /** 전체 체크/해제(로컬) */ + const checkAll = (value) => { + if (!selectedTeam) return; + if (value) { + setPresentSet(new Set(members.map((m) => String(m.userId)))); + } else { + setPresentSet(new Set()); + } + setDirty(true); + }; + + /** 저장(스냅샷) – present=true & present=false 두 번 호출 */ + const saveSnapshot = async () => { + if (!selectedTeam) return; + const allIds = members.map((m) => String(m.userId)); + const presentIds = allIds.filter((id) => presentSet.has(id)); + const absentIds = allIds.filter((id) => !presentSet.has(id)); + + try { + if (presentIds.length) { + await api.saveAttendance(date, presentIds, true, selectedTeam.id); + } + if (absentIds.length) { + await api.saveAttendance(date, absentIds, false, selectedTeam.id); + } + setDirty(false); + await refreshSummary(); + alert('저장되었습니다.'); + } catch { + alert('저장 중 오류가 발생했습니다.'); + } + }; + + const refreshSummary = async () => { + try { + setSummary(await api.summary(date, selectedTeam?.id)); + } catch { + setSummary(null); + } + }; + + return (
+

출석 관리

+ +
+ {/* 날짜 */} + + +
+ 날짜 + +
+ setDate(e.target.value)}/> + +
+ {dates.map((d) => (
+ + +
))} + {dates.length === 0 && (
등록된 날짜가 없습니다.
)} +
+
+
+ + {/* 팀 선택 */} + + +
+ 팀 선택 + +
+ + + +
+ + +
+
+
+ + {/* 요약 */} + + + 요약 + {summary ? (
+
+ 전체 {summary.present} / {summary.total} +
+ +
+ {summary.perTeam.map((ts) => ( +
+ {ts.teamName} + + {ts.present} / {ts.total} + +
))} +
+
) : (
로딩...
)} +
+
+
+ + {/* 팀원 목록 */} + + +
+
+ 팀원 +
+ {date} · {selectedTeam?.name ?? '-'} +
+
+
+ +
+
+ + + +
+ {filteredMembers.map((m) => { + const id = String(m.userId); + const checked = presentSet.has(id); + return (
+
+ toggleMember({userId: id})}> + {m.name} + +
+
); + })} + {filteredMembers.length === 0 && ( +
팀원이 없습니다.
)} +
+
+
+
); +} \ No newline at end of file