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