From d88df88205d89453797a540f3670b6c9c3cd7445 Mon Sep 17 00:00:00 2001
From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com>
Date: Tue, 23 Sep 2025 13:44:16 +0900
Subject: [PATCH 1/2] =?UTF-8?q?feat(core-attendance):=20=EC=B6=9C=EC=84=9D?=
=?UTF-8?q?=20=EA=B4=80=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- /core-attendance 페이지 추가
- 날짜/팀/팀원 기반 출석 관리 UI 구현
- axios 기반 API 클라이언트 작성 및 서버 연동 (dates, teams, members, records, summary)
- 오늘 날짜 추가, 팀원 개별/전체 출석 토글 기능 지원
- 요약 카드 및 팀별 출석 현황 표시
- URL query parameter 동기화 (leadName, teamId, date)
- NextUI 컴포넌트(Card, Input, Button, Checkbox, Select 등) 활용한 UI 구성
---
src/app/core_attendance/layout.js | 11 +
src/app/core_attendance/page.jsx | 372 ++++++++++++++++++++++++++++++
2 files changed, 383 insertions(+)
create mode 100644 src/app/core_attendance/layout.js
create mode 100644 src/app/core_attendance/page.jsx
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..921836a
--- /dev/null
+++ b/src/app/core_attendance/page.jsx
@@ -0,0 +1,372 @@
+'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',
+ timeout: 15000,
+});
+
+const api = {
+ getDates: async () => (await API.get(`/dates`)).data.data,
+ addDate: async (date) => (await API.post(`/dates`, {date})).data.data,
+ deleteDate: async (date) => (await API.delete(`/dates/${date}`)).data.data,
+ getTeams: async (leadName, teamId) => (await API.get(`/teams`, {params: {leadName, teamId}})).data.data,
+ addMember: async (teamId, name) => (await API.post(`/members`, null, {params: {teamId, name}})).data.data,
+ renameMember: async (teamId, memberId, name) => (await API.put(`/members`, null, {
+ params: {
+ teamId,
+ memberId,
+ name
+ }
+ })).data.data,
+ deleteMember: async (teamId, memberId) => (await API.delete(`/members`, {params: {teamId, memberId}})).data.data,
+ setAttendance: async (date, teamId, memberId, present) => (await API.put(`/records/one`, null, {
+ params: {
+ date,
+ teamId,
+ memberId,
+ present
+ }
+ })).data.data,
+ setAll: async (date, teamId, present) => (await API.put(`/records/all`, null, {
+ params: {
+ date,
+ teamId,
+ present
+ }
+ })).data.data,
+ summary: async (date, leadName, teamId) => (await API.get(`/summary`, {
+ params: {
+ date,
+ leadName,
+ 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());
+};
+
+/** ===== Page Component ===== */
+export default function AttendancePage() {
+ const [leadName, setLeadName] = useState(typeof window !== 'undefined' ? getQS('leadName') : '',);
+ const [teamId, setTeamId] = useState(typeof window !== 'undefined' ? getQS('teamId') : '',);
+ const [date, setDate] = useState(typeof window !== 'undefined' ? getQS('date') || ymd() : ymd(),);
+
+ const [dates, setDates] = useState([]);
+ const [teams, setTeams] = useState([]);
+ const [summary, setSummary] = useState(null);
+ const [filter, setFilter] = useState('');
+
+ // 서버에 per-member 조회가 없어서, 프론트에서 토글 상태를 임시 보관
+ const [presentSet, setPresentSet] = useState(new Set());
+
+ const selectedTeam = useMemo(() => teams.find((t) => t.id === teamId) ?? teams[0], [teams, teamId],);
+
+ const filteredMembers = useMemo(() => {
+ if (!selectedTeam) return [];
+ const q = filter.trim();
+ return q ? selectedTeam.members.filter((m) => m.name.includes(q)) : selectedTeam.members;
+ }, [selectedTeam, filter]);
+
+ /** URL 동기화 */
+ useEffect(() => {
+ setQS({
+ date, leadName: leadName || undefined, teamId: teamId || undefined,
+ });
+ }, [date, leadName, teamId]);
+
+ /** 날짜 로드 */
+ useEffect(() => {
+ (async () => {
+ try {
+ const dl = await api.getDates();
+ setDates(dl.dates);
+ if (!dl.dates.includes(date) && dl.dates.length > 0) setDate(dl.dates[0]);
+ } catch (e) {
+ alert('날짜 목록을 불러오지 못했습니다.');
+ }
+ })();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ /** 팀 로드 (leadName 변경 시) */
+ useEffect(() => {
+ (async () => {
+ try {
+ const list = await api.getTeams(leadName || undefined, undefined);
+ setTeams(list);
+ if (list.length && !list.find((t) => t.id === teamId)) setTeamId(list[0].id);
+ setPresentSet(new Set());
+ } catch (e) {
+ alert('팀 목록을 불러오지 못했습니다.');
+ }
+ })();
+ }, [leadName]); // teamId는 선택 결과이므로 의존 X
+
+ /** 요약 로드 */
+ useEffect(() => {
+ if (!date) return;
+ (async () => {
+ try {
+ setSummary(await api.summary(date, leadName || undefined, teamId || undefined));
+ } catch (e) {
+ setSummary(null);
+ alert('요약 정보를 불러오지 못했습니다.');
+ }
+ })();
+ }, [date, leadName, teamId, teams.length]);
+
+ /** 날짜 조작 */
+ const addToday = async () => {
+ try {
+ const d = ymd();
+ await api.addDate(d);
+ const dl = await api.getDates();
+ setDates(dl.dates);
+ setDate(d);
+ } catch (e) {
+ 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 (e) {
+ alert('날짜 삭제에 실패했습니다.');
+ }
+ };
+
+ /** 멤버 조작 */
+ const addMember = async () => {
+ if (!selectedTeam) return;
+ const name = window.prompt('팀원 이름 입력')?.trim();
+ if (!name) return;
+ try {
+ await api.addMember(selectedTeam.id, name);
+ setTeams(await api.getTeams(leadName || undefined));
+ setPresentSet(new Set());
+ await refreshSummary();
+ } catch (e) {
+ alert('팀원 추가에 실패했습니다.');
+ }
+ };
+
+ const renameMember = async (m) => {
+ if (!selectedTeam) return;
+ const name = window.prompt('이름 수정', m.name)?.trim();
+ if (!name) return;
+ try {
+ await api.renameMember(selectedTeam.id, m.id, name);
+ setTeams(await api.getTeams(leadName || undefined));
+ await refreshSummary();
+ } catch (e) {
+ alert('팀원 이름 수정에 실패했습니다.');
+ }
+ };
+
+ const deleteMember = async (m) => {
+ if (!selectedTeam) return;
+ if (!confirm('삭제할까요?')) return;
+ try {
+ await api.deleteMember(selectedTeam.id, m.id);
+ setTeams(await api.getTeams(leadName || undefined));
+ setPresentSet((prev) => {
+ const n = new Set(prev);
+ n.delete(m.id);
+ return n;
+ });
+ await refreshSummary();
+ } catch (e) {
+ alert('팀원 삭제에 실패했습니다.');
+ }
+ };
+
+ /** 출석 체크 */
+ const toggleMember = async (m) => {
+ if (!selectedTeam) return;
+ const next = !presentSet.has(m.id);
+ try {
+ await api.setAttendance(date, selectedTeam.id, m.id, next);
+ setPresentSet((prev) => {
+ const n = new Set(prev);
+ next ? n.add(m.id) : n.delete(m.id);
+ return n;
+ });
+ await refreshSummary();
+ } catch (e) {
+ alert('출석 변경에 실패했습니다.');
+ }
+ };
+
+ const setAll = async (value) => {
+ if (!selectedTeam) return;
+ try {
+ await api.setAll(date, selectedTeam.id, value);
+ setPresentSet(value ? new Set(selectedTeam.members.map((m) => m.id)) : new Set());
+ await refreshSummary();
+ } catch (e) {
+ alert('전체 출석 변경에 실패했습니다.');
+ }
+ };
+
+ const refreshSummary = async () => {
+ try {
+ setSummary(await api.summary(date, leadName || undefined, teamId || undefined));
+ } catch (e) {
+ setSummary(null);
+ }
+ };
+
+ return (
+
출석 관리
+
+
+ {/* 날짜 */}
+
+
+
+ 날짜
+
+
+ setDate(e.target.value)}/>
+
+
+ {dates.map((d) => (
+
+
+
))}
+ {dates.length === 0 && (
등록된 날짜가 없습니다.
)}
+
+
+
+
+ {/* 팀 선택 */}
+
+
+ 팀 선택
+ setLeadName(v)}
+ onBlur={() => setQS({leadName: leadName || undefined})}
+ variant="bordered"
+ />
+
+ {/* ✅ NextUI v2 권장 컨트롤 패턴: onSelectionChange / selectedKeys(Set) */}
+
+
+
+
+
+
+
+
+
+ {/* 요약 */}
+
+
+ 요약
+ {summary ? (
+
+ 전체 {summary.present} / {summary.total}
+
+
+
+ {summary.perTeam.map((ts) => (
+
+ {ts.teamName}
+
+ {ts.present} / {ts.total}
+
+
))}
+
+
) : (로딩...
)}
+
+
+
+
+ {/* 팀원 목록 */}
+
+
+
+
+
팀원
+
+ {date} · {selectedTeam?.name ?? '-'}
+
+
+
+
+
+
+
+
+
+
+
+ {filteredMembers.map((m) => {
+ const checked = presentSet.has(m.id);
+ return (
+
+ toggleMember(m)}>
+ {m.name}
+
+
+
+
+
+
+
);
+ })}
+ {filteredMembers.length === 0 && (
+
팀원이 없습니다.
)}
+
+
+
+
);
+}
\ No newline at end of file
From 8c3a63ca524bd81489f5fed880908446784e9a44 Mon Sep 17 00:00:00 2001
From: JM Kim <106949557+CSE-Shaco@users.noreply.github.com>
Date: Wed, 22 Oct 2025 00:44:00 +0900
Subject: [PATCH 2/2] =?UTF-8?q?feat(core-attendance/front):=20meetings=20A?=
=?UTF-8?q?PI=EB=A1=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?=
=?UTF-8?q?=EC=85=98=20=EB=B0=8F=20=EC=B6=9C=EC=84=9D=20UX=20=EA=B0=9C?=
=?UTF-8?q?=ED=8E=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- API 베이스 경로를 로 변경
- 날짜 목록: GET 사용(오늘 추가/삭제 지원)
- 팀 목록: GET (권한 기반 서버 필터)
- 멤버+출석 현황: GET 로딩 후 체크박스 초기화
- 출석 저장(스냅샷): PUT { userIds, present }
- 요약: GET 재조회로 카운트 동기화
- URL 파라미터 동기화 유지:
- 낙관적 업데이트 적용 및 오류시 롤백 최소화
- 멤버 CRUD UI 제거(운영자 전용 화면으로 분리 예정)
- axios withCredentials 활성화(리프레시 토큰 대응)
BREAKING CHANGE:
구 엔드포인트(, , ) 의존 제거.
이제 출석 저장은 날짜 기준 스냅샷(사용자 ID 목록 + present)으로 전송합니다.
Affects: src/app/core_attendance/page.jsx
Test Plan:
- [ ] 날짜 목록 로드/추가/삭제
- [ ] 리드 계정: 결과가 본인 팀만 포함되는지 확인
- [ ] 개별 체크 토글 → PUT 저장 → 요약 재조회 일관성 확인
- [ ] 전체 체크/해제 동작 및 요약 일관성 확인
- [ ] 관리자 계정: 쿼리로 필터 동작 확인
- [ ] 토큰 만료 시 withCredentials 기반 재발급(있을 경우) 확인
---
src/app/core_attendance/page.jsx | 278 +++++++++++++++----------------
1 file changed, 132 insertions(+), 146 deletions(-)
diff --git a/src/app/core_attendance/page.jsx b/src/app/core_attendance/page.jsx
index 921836a..6640bcf 100644
--- a/src/app/core_attendance/page.jsx
+++ b/src/app/core_attendance/page.jsx
@@ -6,46 +6,31 @@ import {Button, Card, CardBody, Checkbox, Divider, Input, Select, SelectItem} fr
/** ===== API 클라이언트 ===== */
const API = axios.create({
- baseURL: process.env.NEXT_PUBLIC_BASE_API_URL?.replace(/\/$/, '') || 'http://localhost:8080/api/v1/core-attendance',
+ baseURL: (process.env.NEXT_PUBLIC_BASE_API_URL?.replace(/\/$/, '') || 'http://localhost:8080') + '/api/v1/core-attendance/meetings',
timeout: 15000,
+ withCredentials: true,
});
const api = {
- getDates: async () => (await API.get(`/dates`)).data.data,
- addDate: async (date) => (await API.post(`/dates`, {date})).data.data,
- deleteDate: async (date) => (await API.delete(`/dates/${date}`)).data.data,
- getTeams: async (leadName, teamId) => (await API.get(`/teams`, {params: {leadName, teamId}})).data.data,
- addMember: async (teamId, name) => (await API.post(`/members`, null, {params: {teamId, name}})).data.data,
- renameMember: async (teamId, memberId, name) => (await API.put(`/members`, null, {
- params: {
- teamId,
- memberId,
- name
- }
- })).data.data,
- deleteMember: async (teamId, memberId) => (await API.delete(`/members`, {params: {teamId, memberId}})).data.data,
- setAttendance: async (date, teamId, memberId, present) => (await API.put(`/records/one`, null, {
- params: {
- date,
- teamId,
- memberId,
- present
- }
- })).data.data,
- setAll: async (date, teamId, present) => (await API.put(`/records/all`, null, {
- params: {
- date,
- teamId,
- present
- }
- })).data.data,
- summary: async (date, leadName, teamId) => (await API.get(`/summary`, {
- params: {
- date,
- leadName,
- teamId
- }
- })).data.data,
+ // 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,
};
/** ===== 유틸 ===== */
@@ -54,79 +39,99 @@ const getQS = (k) => typeof window !== 'undefined' ? new URL(window.location.hre
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),);
+ Object.entries(entries).forEach(([k, v]) => (v ? u.searchParams.set(k, v) : u.searchParams.delete(k)));
window.history.replaceState({}, '', u.toString());
};
-/** ===== Page Component ===== */
export default function AttendancePage() {
- const [leadName, setLeadName] = useState(typeof window !== 'undefined' ? getQS('leadName') : '',);
- const [teamId, setTeamId] = useState(typeof window !== 'undefined' ? getQS('teamId') : '',);
- const [date, setDate] = useState(typeof window !== 'undefined' ? getQS('date') || ymd() : ymd(),);
+ // 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('');
- // 서버에 per-member 조회가 없어서, 프론트에서 토글 상태를 임시 보관
+ // 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 selectedTeam = useMemo(() => teams.find((t) => t.id === teamId) ?? teams[0], [teams, teamId]);
const filteredMembers = useMemo(() => {
- if (!selectedTeam) return [];
+ const base = members;
const q = filter.trim();
- return q ? selectedTeam.members.filter((m) => m.name.includes(q)) : selectedTeam.members;
- }, [selectedTeam, filter]);
+ if (!q) return base;
+ return base.filter((m) => m.name.includes(q));
+ }, [members, filter]);
/** URL 동기화 */
useEffect(() => {
- setQS({
- date, leadName: leadName || undefined, teamId: teamId || undefined,
- });
- }, [date, leadName, teamId]);
+ setQS({date, teamId: teamId || undefined});
+ }, [date, teamId]);
/** 날짜 로드 */
useEffect(() => {
(async () => {
try {
- const dl = await api.getDates();
+ const dl = await api.getDates(); // { dates: [...] }
setDates(dl.dates);
if (!dl.dates.includes(date) && dl.dates.length > 0) setDate(dl.dates[0]);
- } catch (e) {
+ } catch {
alert('날짜 목록을 불러오지 못했습니다.');
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
- /** 팀 로드 (leadName 변경 시) */
+ /** 팀 로드 */
useEffect(() => {
(async () => {
try {
- const list = await api.getTeams(leadName || undefined, undefined);
+ const list = await api.getTeams();
setTeams(list);
if (list.length && !list.find((t) => t.id === teamId)) setTeamId(list[0].id);
- setPresentSet(new Set());
- } catch (e) {
+ } catch {
alert('팀 목록을 불러오지 못했습니다.');
}
})();
- }, [leadName]); // teamId는 선택 결과이므로 의존 X
+ // 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(() => {
- if (!date) return;
(async () => {
+ if (!date) return;
try {
- setSummary(await api.summary(date, leadName || undefined, teamId || undefined));
- } catch (e) {
+ const s = await api.summary(date, selectedTeam?.id);
+ setSummary(s);
+ } catch {
setSummary(null);
- alert('요약 정보를 불러오지 못했습니다.');
}
})();
- }, [date, leadName, teamId, teams.length]);
+ }, [date, selectedTeam?.id]); // eslint-disable-line react-hooks/exhaustive-deps
/** 날짜 조작 */
const addToday = async () => {
@@ -136,98 +141,87 @@ export default function AttendancePage() {
const dl = await api.getDates();
setDates(dl.dates);
setDate(d);
- } catch (e) {
+ } 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 (e) {
+ } catch {
alert('날짜 삭제에 실패했습니다.');
}
};
- /** 멤버 조작 */
- const addMember = async () => {
+ /** 개별 토글: 배치 API로 “단건” 반영 */
+ const toggleMember = async (m) => {
if (!selectedTeam) return;
- const name = window.prompt('팀원 이름 입력')?.trim();
- if (!name) return;
- try {
- await api.addMember(selectedTeam.id, name);
- setTeams(await api.getTeams(leadName || undefined));
- setPresentSet(new Set());
- await refreshSummary();
- } catch (e) {
- alert('팀원 추가에 실패했습니다.');
- }
- };
+ const id = String(m.id ?? m.userId);
+ const next = !presentSet.has(id);
- const renameMember = async (m) => {
- if (!selectedTeam) return;
- const name = window.prompt('이름 수정', m.name)?.trim();
- if (!name) return;
- try {
- await api.renameMember(selectedTeam.id, m.id, name);
- setTeams(await api.getTeams(leadName || undefined));
- await refreshSummary();
- } catch (e) {
- alert('팀원 이름 수정에 실패했습니다.');
- }
- };
+ // UI 낙관적 업데이트
+ setPresentSet((prev) => {
+ const n = new Set(prev);
+ next ? n.add(id) : n.delete(id);
+ return n;
+ });
+ setDirty(true);
- const deleteMember = async (m) => {
- if (!selectedTeam) return;
- if (!confirm('삭제할까요?')) return;
try {
- await api.deleteMember(selectedTeam.id, m.id);
- setTeams(await api.getTeams(leadName || undefined));
+ await api.saveAttendance(date, [id], next, selectedTeam.id);
+ await refreshSummary();
+ } catch {
+ alert('출석 변경에 실패했습니다.');
+ // 롤백
setPresentSet((prev) => {
const n = new Set(prev);
- n.delete(m.id);
+ next ? n.delete(id) : n.add(id);
return n;
});
- await refreshSummary();
- } catch (e) {
- alert('팀원 삭제에 실패했습니다.');
}
};
- /** 출석 체크 */
- const toggleMember = async (m) => {
+ /** 전체 체크/해제(로컬) */
+ const checkAll = (value) => {
if (!selectedTeam) return;
- const next = !presentSet.has(m.id);
- try {
- await api.setAttendance(date, selectedTeam.id, m.id, next);
- setPresentSet((prev) => {
- const n = new Set(prev);
- next ? n.add(m.id) : n.delete(m.id);
- return n;
- });
- await refreshSummary();
- } catch (e) {
- alert('출석 변경에 실패했습니다.');
+ if (value) {
+ setPresentSet(new Set(members.map((m) => String(m.userId))));
+ } else {
+ setPresentSet(new Set());
}
+ setDirty(true);
};
- const setAll = async (value) => {
+ /** 저장(스냅샷) – 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 {
- await api.setAll(date, selectedTeam.id, value);
- setPresentSet(value ? new Set(selectedTeam.members.map((m) => m.id)) : new Set());
+ 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();
- } catch (e) {
- alert('전체 출석 변경에 실패했습니다.');
+ alert('저장되었습니다.');
+ } catch {
+ alert('저장 중 오류가 발생했습니다.');
}
};
const refreshSummary = async () => {
try {
- setSummary(await api.summary(date, leadName || undefined, teamId || undefined));
- } catch (e) {
+ setSummary(await api.summary(date, selectedTeam?.id));
+ } catch {
setSummary(null);
}
};
@@ -264,24 +258,26 @@ export default function AttendancePage() {
{/* 팀 선택 */}
- 팀 선택
- setLeadName(v)}
- onBlur={() => setQS({leadName: leadName || undefined})}
- variant="bordered"
- />
+
+ 팀 선택
+
+
- {/* ✅ NextUI v2 권장 컨트롤 패턴: onSelectionChange / selectedKeys(Set) */}
-
@@ -336,9 +332,6 @@ export default function AttendancePage() {
-
- 팀원 추가
-
@@ -346,21 +339,14 @@ export default function AttendancePage() {
{filteredMembers.map((m) => {
- const checked = presentSet.has(m.id);
- return (
+ const id = String(m.userId);
+ const checked = presentSet.has(id);
+ return (
- toggleMember(m)}>
+ toggleMember({userId: id})}>
{m.name}
-
- renameMember(m)}>
- 수정
-
- deleteMember(m)}>
- 삭제
-
-
);
})}
{filteredMembers.length === 0 && (