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}
-
- - -
); })} {filteredMembers.length === 0 && (