From d52dc581bfc2979cc68cc4ae3594942c827282b6 Mon Sep 17 00:00:00 2001 From: Kim Minhyeok Date: Thu, 23 Apr 2026 23:37:22 +0900 Subject: [PATCH 01/14] =?UTF-8?q?add:=20=EB=A7=88=EC=9D=84/=EB=8B=A4?= =?UTF-8?q?=EB=9D=BD=EB=B0=A9=20=EC=B6=9C=EC=84=9D=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20(=EC=9E=85=EB=A0=A5=20=ED=83=AD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /admin/soon/attendance 에 [조회] / [입력] 탭 분리 - [입력] 탭: 마을/다락방/순원 3열 리스트 + 모바일 드릴다운 - 상태 필터 5개 (전체/기록안됨/출석/결석/기타) + 이름 검색 - 그룹별 tri-state 체크박스 (마을/다락방/전체 선택) - 하단 sticky bulk action bar (출석/결석/기타) - 공통 사유 다이얼로그 + Undo 스낵바 (10초) - 서버: /admin/soon/update-attendance 엔드포인트 + 권한 검증 (Admin / VillageLeader / Leader 각자 스코프 내만 편집 가능) - 부수: /leader/all-attendance 의 authUserData race condition 수정 --- .../app/admin/soon/attendance/AttendCell.tsx | 190 ++- .../admin/soon/attendance/AttendanceTable.tsx | 21 +- .../src/app/admin/soon/attendance/EditTab.tsx | 1072 +++++++++++++++++ .../app/admin/soon/attendance/OverviewTab.tsx | 251 ++++ client/src/app/admin/soon/attendance/page.tsx | 214 +--- client/src/app/leader/all-attendance/page.tsx | 58 +- server/src/routes/admin/soonRouter.ts | 89 +- 7 files changed, 1646 insertions(+), 249 deletions(-) create mode 100644 client/src/app/admin/soon/attendance/EditTab.tsx create mode 100644 client/src/app/admin/soon/attendance/OverviewTab.tsx diff --git a/client/src/app/admin/soon/attendance/AttendCell.tsx b/client/src/app/admin/soon/attendance/AttendCell.tsx index 18176eff..21bd9cbe 100644 --- a/client/src/app/admin/soon/attendance/AttendCell.tsx +++ b/client/src/app/admin/soon/attendance/AttendCell.tsx @@ -1,68 +1,154 @@ -import { Box } from "@mui/material" +import { useState } from "react" +import { + Box, + Popover, + Select, + MenuItem, + TextField, + Button, + Stack, + Typography, +} from "@mui/material" import { AttendData } from "@server/entity/attendData" import { AttendStatus } from "@server/entity/types" +import CheckCircleIcon from "@mui/icons-material/CheckCircle" +import CancelIcon from "@mui/icons-material/Cancel" +import HelpIcon from "@mui/icons-material/Help" interface AttendCellProps { attendData: AttendData | undefined + editable?: boolean + onSave?: (status: AttendStatus, memo: string) => Promise | void } -export default function AttendCell({ attendData }: AttendCellProps) { - if (!attendData) { - return ( - - - - - ) +export default function AttendCell({ + attendData, + editable = false, + onSave, +}: AttendCellProps) { + const [anchorEl, setAnchorEl] = useState(null) + const [status, setStatus] = useState(AttendStatus.ATTEND) + const [memo, setMemo] = useState("") + const [saving, setSaving] = useState(false) + + function handleOpen(e: React.MouseEvent) { + if (!editable) return + setStatus(attendData?.isAttend ?? AttendStatus.ATTEND) + setMemo(attendData?.memo ?? "") + setAnchorEl(e.currentTarget) } - if (attendData.isAttend === AttendStatus.ATTEND) { - return ( - - 출석 - - ) + async function handleSave() { + if (!onSave) return + setSaving(true) + try { + await onSave(status, memo) + setAnchorEl(null) + } finally { + setSaving(false) + } } - if (attendData.isAttend === AttendStatus.ABSENT) { - return ( - - {attendData.memo} - - ) + let bg = "transparent" + let label: React.ReactNode = "-" + + if (attendData?.isAttend === AttendStatus.ATTEND) { + bg = "rgb(184, 248, 93)" + label = "출석" + } else if (attendData?.isAttend === AttendStatus.ABSENT) { + bg = "rgb(240, 148, 128)" + label = attendData.memo || "결석" + } else if (attendData?.isAttend === AttendStatus.ETC) { + bg = "rgb(253, 241, 113)" + label = attendData.memo || "기타" } - if (attendData.isAttend === AttendStatus.ETC) { - return ( + return ( + <> - {attendData.memo} + {label} - ) - } -} \ No newline at end of file + {editable && ( + { + if (!saving) setAnchorEl(null) + }} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + transformOrigin={{ vertical: "top", horizontal: "center" }} + > + + + 출석 수정 + + + {status !== AttendStatus.ATTEND && ( + setMemo(e.target.value)} + autoFocus + /> + )} + + + + + + + )} + + ) +} diff --git a/client/src/app/admin/soon/attendance/AttendanceTable.tsx b/client/src/app/admin/soon/attendance/AttendanceTable.tsx index cac067c6..545b43cd 100644 --- a/client/src/app/admin/soon/attendance/AttendanceTable.tsx +++ b/client/src/app/admin/soon/attendance/AttendanceTable.tsx @@ -2,6 +2,7 @@ import { Box, Stack, Typography, Paper, Chip } from "@mui/material" import { User } from "@server/entity/user" import { AttendData } from "@server/entity/attendData" import { WorshipSchedule } from "@server/entity/worshipSchedule" +import { AttendStatus } from "@server/entity/types" import AttendCell from "./AttendCell" import StarIcon from "@mui/icons-material/Star" import StarBorderIcon from "@mui/icons-material/StarBorder" @@ -15,6 +16,13 @@ interface AttendanceTableProps { attendDataList: AttendData[], worshipScheduleId: number, ) => { count: number; attend: number } + editable?: boolean + onSaveCell?: ( + userId: string, + worshipScheduleId: number, + status: AttendStatus, + memo: string, + ) => Promise } export default function AttendanceTable({ @@ -23,6 +31,8 @@ export default function AttendanceTable({ worshipScheduleMapList, leaders, getAttendUserCount, + editable = false, + onSaveCell, }: AttendanceTableProps) { const isMobile = global.innerWidth < 600 return ( @@ -197,7 +207,16 @@ export default function AttendanceTable({ justifyContent: "center", }} > - + + onSaveCell(user.id, worshipSchedule.id, status, memo) + : undefined + } + /> ) })} diff --git a/client/src/app/admin/soon/attendance/EditTab.tsx b/client/src/app/admin/soon/attendance/EditTab.tsx new file mode 100644 index 00000000..1058ab43 --- /dev/null +++ b/client/src/app/admin/soon/attendance/EditTab.tsx @@ -0,0 +1,1072 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { + Box, + Stack, + Typography, + Select, + MenuItem, + FormControl, + InputLabel, + TextField, + Checkbox, + Button, + Chip, + Paper, + CircularProgress, + Snackbar, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + IconButton, + useMediaQuery, + useTheme, +} from "@mui/material" +import ChevronRightIcon from "@mui/icons-material/ChevronRight" +import ArrowBackIcon from "@mui/icons-material/ArrowBack" +import CheckCircleIcon from "@mui/icons-material/CheckCircle" +import CancelIcon from "@mui/icons-material/Cancel" +import HelpIcon from "@mui/icons-material/Help" + +import axios from "@/config/axios" +import { get } from "@/config/api" +import { AttendData } from "@server/entity/attendData" +import { AttendStatus } from "@server/entity/types" +import { Community } from "@server/entity/community" +import { User } from "@server/entity/user" +import { WorshipSchedule } from "@server/entity/worshipSchedule" +import { worshipKr } from "@/util/worship" +import { useNotification } from "@/hooks/useNotification" + +type StatusFilter = "all" | "unrecorded" | "ATTEND" | "ABSENT" | "ETC" + +export default function EditTab() { + const [communities, setCommunities] = useState([]) + const [allUsers, setAllUsers] = useState([]) + const [schedules, setSchedules] = useState([]) + const [selectedScheduleId, setSelectedScheduleId] = useState("") + const [attendData, setAttendData] = useState([]) + const [statusFilter, setStatusFilter] = useState("all") + const [searchText, setSearchText] = useState("") + const [checkedIds, setCheckedIds] = useState>(new Set()) + const [focusedVillageId, setFocusedVillageId] = useState(null) + const [focusedDarakId, setFocusedDarakId] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + + // Phase 3: Undo 스낵바 + const [undoAction, setUndoAction] = useState<{ + userIds: string[] + previousStates: Map + newStatus: AttendStatus + scheduleId: number + } | null>(null) + + // Phase 3: 공통 사유 다이얼로그 + const [memoDialog, setMemoDialog] = useState<{ + status: AttendStatus + memo: string + } | null>(null) + + const { success, error } = useNotification() + + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down("md")) + + // 초기 로드 + useEffect(() => { + ;(async () => { + try { + const [commData, schedResp] = await Promise.all([ + get("/admin/community"), + axios.get("/soon/worship-schedule"), + ]) + setCommunities(commData) + setSchedules(schedResp.data) + if (schedResp.data.length > 0) { + setSelectedScheduleId(schedResp.data[0].id) + } + } catch (e) { + error("데이터 로드 실패") + } finally { + setLoading(false) + } + })() + }, []) + + // 전체 유저 로드 + useEffect(() => { + if (communities.length === 0) return + const topIds = communities + .filter((c) => !c.parent) + .map((c) => c.id) + .join(",") + if (!topIds) return + axios + .post("/admin/soon/get-soon-list", { ids: topIds }) + .then((resp) => setAllUsers(resp.data)) + }, [communities]) + + // 선택된 예배의 출석 데이터 로드 + useEffect(() => { + if (allUsers.length === 0 || !selectedScheduleId) return + const userIds = allUsers.map((u) => u.id).join(",") + axios + .post("/admin/soon/user-attendance", { ids: userIds }) + .then((resp) => { + const filtered = resp.data.filter( + (d) => d.worshipSchedule.id === selectedScheduleId, + ) + setAttendData(filtered) + }) + }, [allUsers, selectedScheduleId]) + + // Memos + const attendMap = useMemo(() => { + const m = new Map() + attendData.forEach((d) => m.set(d.user.id, d)) + return m + }, [attendData]) + + const parentMap = useMemo(() => { + const m = new Map() + communities.forEach((c) => m.set(c.id, c.parent?.id ?? null)) + return m + }, [communities]) + + const nameMap = useMemo(() => { + const m = new Map() + communities.forEach((c) => m.set(c.id, c.name)) + return m + }, [communities]) + + function getUserStatus(userId: string): StatusFilter { + const d = attendMap.get(userId) + if (!d) return "unrecorded" + return d.isAttend as StatusFilter + } + + function findVillageId(darakId: number): number { + let cur = darakId + for (let i = 0; i < 10; i++) { + const parent = parentMap.get(cur) + if (parent === null || parent === undefined) return cur + cur = parent + } + return cur + } + + // 필터링된 유저 + const filteredUsers = useMemo(() => { + return allUsers.filter((u) => { + const status = getUserStatus(u.id) + if (statusFilter !== "all" && status !== statusFilter) return false + if (searchText && !(u.name || "").includes(searchText)) return false + return true + }) + }, [allUsers, statusFilter, searchText, attendMap]) + + // 마을별 유저 매핑 + const usersByVillage = useMemo(() => { + const m = new Map() + filteredUsers.forEach((u) => { + if (!u.community) return + const vid = findVillageId(u.community.id) + if (!m.has(vid)) m.set(vid, []) + m.get(vid)!.push(u) + }) + return m + }, [filteredUsers, parentMap]) + + // 다락방별 유저 매핑 + const usersByDarak = useMemo(() => { + const m = new Map() + filteredUsers.forEach((u) => { + if (!u.community) return + if (!m.has(u.community.id)) m.set(u.community.id, []) + m.get(u.community.id)!.push(u) + }) + return m + }, [filteredUsers]) + + // 컬럼 1: 최상위 마을들 + const villagesCol = useMemo(() => { + return communities + .filter((c) => !c.parent) + .sort((a, b) => a.id - b.id) + }, [communities]) + + // 컬럼 2: 포커스된 마을의 직계 다락방들 + const daraksCol = useMemo(() => { + if (!focusedVillageId) return [] + return communities + .filter((c) => c.parent?.id === focusedVillageId) + .sort((a, b) => a.id - b.id) + }, [communities, focusedVillageId]) + + // 컬럼 3: 포커스된 다락방의 순원들 (필터링된 것 중) + const usersCol = useMemo(() => { + if (!focusedDarakId) return [] + const users = usersByDarak.get(focusedDarakId) || [] + return [...users].sort((a, b) => { + const aLead = + a.community?.leader?.id === a.id + ? -2 + : a.community?.deputyLeader?.id === a.id + ? -1 + : 0 + const bLead = + b.community?.leader?.id === b.id + ? -2 + : b.community?.deputyLeader?.id === b.id + ? -1 + : 0 + if (aLead !== bLead) return aLead - bLead + return (a.name || "").localeCompare(b.name || "") + }) + }, [usersByDarak, focusedDarakId]) + + // 검색 시 평면 리스트용 (마을 → 다락방 → 이름 순) + const searchResultUsers = useMemo(() => { + return [...filteredUsers].sort((a, b) => { + const aVid = a.community ? findVillageId(a.community.id) : 0 + const bVid = b.community ? findVillageId(b.community.id) : 0 + if (aVid !== bVid) return aVid - bVid + const aDid = a.community?.id || 0 + const bDid = b.community?.id || 0 + if (aDid !== bDid) return aDid - bDid + return (a.name || "").localeCompare(b.name || "") + }) + }, [filteredUsers, parentMap]) + + // 필터 변경으로 포커스 그룹이 비었을 경우 자동 해제 + useEffect(() => { + if ( + focusedVillageId && + (usersByVillage.get(focusedVillageId)?.length ?? 0) === 0 + ) { + setFocusedVillageId(null) + setFocusedDarakId(null) + } else if ( + focusedDarakId && + (usersByDarak.get(focusedDarakId)?.length ?? 0) === 0 + ) { + setFocusedDarakId(null) + } + }, [usersByVillage, usersByDarak]) + + // 필터 chip 카운트 + const counts = useMemo(() => { + const base = searchText + ? allUsers.filter((u) => (u.name || "").includes(searchText)) + : allUsers + const c = { all: base.length, unrecorded: 0, ATTEND: 0, ABSENT: 0, ETC: 0 } + base.forEach((u) => { + const s = getUserStatus(u.id) + if (s === "unrecorded") c.unrecorded++ + else (c as any)[s]++ + }) + return c + }, [allUsers, attendMap, searchText]) + + // 선택 헬퍼 + function toggleUser(id: string) { + setCheckedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + function getGroupState(users: User[]): "none" | "some" | "all" { + if (users.length === 0) return "none" + const n = users.filter((u) => checkedIds.has(u.id)).length + if (n === 0) return "none" + if (n === users.length) return "all" + return "some" + } + + function toggleGroup(users: User[]) { + const state = getGroupState(users) + setCheckedIds((prev) => { + const next = new Set(prev) + if (state === "all") { + users.forEach((u) => next.delete(u.id)) + } else { + users.forEach((u) => next.add(u.id)) + } + return next + }) + } + + // Bulk 저장 진입점 — ABSENT/ETC는 사유 다이얼로그 경유 + function handleBulkSave(status: AttendStatus) { + if (!selectedScheduleId) return + if (checkedIds.size === 0) return + + if (status === AttendStatus.ABSENT || status === AttendStatus.ETC) { + // 공통 사유 다이얼로그 오픈 + setMemoDialog({ status, memo: "" }) + return + } + // ATTEND는 다이얼로그 없이 바로 + runBulkSave(status, "") + } + + async function runBulkSave(status: AttendStatus, memo: string) { + if (!selectedScheduleId) return + const ids = Array.from(checkedIds) + if (ids.length === 0) return + + // Phase 3: 스냅샷 저장 (undo용) + const previousStates = new Map< + string, + { status: StatusFilter; memo: string } + >() + ids.forEach((userId) => { + previousStates.set(userId, { + status: getUserStatus(userId), + memo: attendMap.get(userId)?.memo || "", + }) + }) + + setSaving(true) + const results = await Promise.allSettled( + ids.map((userId) => + axios.post("/admin/soon/update-attendance", { + userId, + worshipScheduleId: selectedScheduleId, + isAttend: status, + memo, + }), + ), + ) + const successfulIds: string[] = [] + ids.forEach((id, i) => { + if (results[i].status === "fulfilled") successfulIds.push(id) + }) + + setAttendData((prev) => { + const map = new Map(prev.map((d) => [d.user.id, d])) + successfulIds.forEach((userId) => { + const existing = map.get(userId) + if (existing) { + map.set(userId, { ...existing, isAttend: status, memo }) + } else { + map.set(userId, { + id: "local-" + userId, + user: { id: userId } as User, + worshipSchedule: { + id: Number(selectedScheduleId), + } as WorshipSchedule, + isAttend: status, + memo, + } as AttendData) + } + }) + return Array.from(map.values()) + }) + + setSaving(false) + const failed = ids.length - successfulIds.length + if (failed > 0) { + error(`${failed}건 저장 실패`) + } + + // Phase 3: Undo 액션 준비 (성공한 것만) + if (successfulIds.length > 0) { + const snapshot = new Map< + string, + { status: StatusFilter; memo: string } + >() + successfulIds.forEach((id) => { + const prev = previousStates.get(id) + if (prev) snapshot.set(id, prev) + }) + setUndoAction({ + userIds: successfulIds, + previousStates: snapshot, + newStatus: status, + scheduleId: Number(selectedScheduleId), + }) + } + setCheckedIds(new Set()) + } + + // Phase 3: Undo 실행 + async function handleUndo() { + if (!undoAction) return + const action = undoAction + setUndoAction(null) // 스낵바 먼저 닫기 + + const restorable = action.userIds.filter( + (id) => action.previousStates.get(id)?.status !== "unrecorded", + ) + const unrecoverable = action.userIds.length - restorable.length + + if (restorable.length === 0) { + error("이전 상태가 '기록안됨'이라 복구할 수 없습니다") + return + } + + const results = await Promise.allSettled( + restorable.map((userId) => { + const prev = action.previousStates.get(userId)! + return axios.post("/admin/soon/update-attendance", { + userId, + worshipScheduleId: action.scheduleId, + isAttend: prev.status, + memo: prev.memo, + }) + }), + ) + const successIds = restorable.filter( + (_, i) => results[i].status === "fulfilled", + ) + + // 로컬 상태 복원 + setAttendData((prev) => { + const map = new Map(prev.map((d) => [d.user.id, d])) + successIds.forEach((userId) => { + const target = action.previousStates.get(userId)! + const existing = map.get(userId) + if (existing) { + map.set(userId, { + ...existing, + isAttend: target.status as AttendStatus, + memo: target.memo, + }) + } + }) + return Array.from(map.values()) + }) + + let msg = `${successIds.length}명 복구됨` + if (unrecoverable > 0) msg += ` (${unrecoverable}명은 복구 불가)` + success(msg) + } + + function statusChip(status: StatusFilter, memo?: string) { + if (status === "ATTEND") + return ( + + ) + if (status === "ABSENT") + return ( + + ) + if (status === "ETC") + return ( + + ) + return null + } + + const hiddenSelectedCount = useMemo(() => { + const visible = new Set(filteredUsers.map((u) => u.id)) + let count = 0 + checkedIds.forEach((id) => { + if (!visible.has(id)) count++ + }) + return count + }, [filteredUsers, checkedIds]) + + if (loading) { + return ( + + + + ) + } + + return ( + + {/* 예배 선택 */} + + + 예배 + + + + + {/* 상태 필터 */} + + + 상태별 필터 + + + {( + [ + { k: "all", label: "전체", count: counts.all }, + { k: "unrecorded", label: "기록안됨", count: counts.unrecorded }, + { k: "ATTEND", label: "출석", count: counts.ATTEND }, + { k: "ABSENT", label: "결석", count: counts.ABSENT }, + { k: "ETC", label: "기타", count: counts.ETC }, + ] as { k: StatusFilter; label: string; count: number }[] + ).map(({ k, label, count }) => ( + setStatusFilter(k)} + disabled={k !== "all" && count === 0} + /> + ))} + + + + {/* 검색 */} + + setSearchText(e.target.value)} + /> + + + {/* 3-column 리스트 */} + + {/* 전체 선택 헤더 */} + + 0 && + filteredUsers.every((u) => checkedIds.has(u.id)) + } + indeterminate={ + filteredUsers.some((u) => checkedIds.has(u.id)) && + !filteredUsers.every((u) => checkedIds.has(u.id)) + } + onChange={() => toggleGroup(filteredUsers)} + disabled={filteredUsers.length === 0} + /> + + 전체 선택 (표시 중 {filteredUsers.length}명) + + + + {searchText ? ( + /* 검색 중: 평면 리스트 */ + + + + 검색 결과 ({searchResultUsers.length}명) + + + + {searchResultUsers.length === 0 ? ( + 검색 결과가 없습니다 + ) : ( + searchResultUsers.map((u) => { + const status = getUserStatus(u.id) + const memo = attendMap.get(u.id)?.memo + const isLeader = u.community?.leader?.id === u.id + const isDeputy = u.community?.deputyLeader?.id === u.id + const checked = checkedIds.has(u.id) + const vId = u.community + ? findVillageId(u.community.id) + : null + const vName = vId ? nameMap.get(vId) : "" + const dName = u.community + ? nameMap.get(u.community.id) + : "" + return ( + toggleUser(u.id)} + > + e.stopPropagation()} + onChange={() => toggleUser(u.id)} + /> + + + + {u.name} + + {(isLeader || isDeputy) && ( + + )} + + + {vName} › {dName} · {u.yearOfBirth}년생 ·{" "} + {u.gender === "man" ? "남" : "여"} + + + {statusChip(status, memo)} + + ) + }) + )} + + + ) : ( + <> + {/* 모바일 전용: 뒤로가기 + 경로 */} + {isMobile && focusedVillageId != null && ( + + { + if (focusedDarakId != null) setFocusedDarakId(null) + else setFocusedVillageId(null) + }} + > + + + + {nameMap.get(focusedVillageId)} + {focusedDarakId != null && + ` › ${nameMap.get(focusedDarakId)}`} + + + )} + + + {/* 컬럼 1: 마을 — 모바일에선 focused 상태일 때 숨김 */} + {(!isMobile || focusedVillageId == null) && ( + + {villagesCol.map((v) => { + const users = usersByVillage.get(v.id) || [] + const count = users.length + const state = getGroupState(users) + const isFocused = focusedVillageId === v.id + return ( + { + setFocusedVillageId(v.id) + setFocusedDarakId(null) + }} + > + e.stopPropagation()} + onChange={() => toggleGroup(users)} + /> + + + {v.name} + + + {count}명 + + + + + ) + })} + + + )} + + {/* 컬럼 2: 다락방 — 모바일에선 마을 선택됐고 다락방 미선택일 때만 */} + {(!isMobile || + (focusedVillageId != null && focusedDarakId == null)) && ( + + {!focusedVillageId ? ( + 마을을 선택하세요 + ) : daraksCol.length === 0 ? ( + 하위 다락방 없음 + ) : ( + daraksCol.map((d) => { + const users = usersByDarak.get(d.id) || [] + const count = users.length + const state = getGroupState(users) + const isFocused = focusedDarakId === d.id + return ( + setFocusedDarakId(d.id)} + > + e.stopPropagation()} + onChange={() => toggleGroup(users)} + /> + + + {d.name} + + + {count}명 + + + + + ) + }) + )} + + + )} + + {/* 컬럼 3: 순원 — 모바일에선 다락방 선택됐을 때만 */} + {(!isMobile || focusedDarakId != null) && ( + + {!focusedDarakId ? ( + 다락방을 선택하세요 + ) : usersCol.length === 0 ? ( + 해당 조건의 순원 없음 + ) : ( + usersCol.map((u) => { + const status = getUserStatus(u.id) + const memo = attendMap.get(u.id)?.memo + const isLeader = u.community?.leader?.id === u.id + const isDeputy = u.community?.deputyLeader?.id === u.id + const checked = checkedIds.has(u.id) + return ( + toggleUser(u.id)} + > + e.stopPropagation()} + onChange={() => toggleUser(u.id)} + /> + + + {u.name} + {(isLeader || isDeputy) && ( + + )} + + + {u.yearOfBirth}년생 · {u.gender === "man" ? "남" : "여"} + + + {statusChip(status, memo)} + + ) + }) + )} + + )} + + + )} + + + {/* Phase 3: 공통 사유 다이얼로그 */} + setMemoDialog(null)} + fullWidth + maxWidth="xs" + > + + {memoDialog?.status === AttendStatus.ABSENT ? "결석" : "기타"} 사유 + + + + {checkedIds.size}명에게 공통으로 적용할 사유 (비워두면 각자 빈칸) + + + memoDialog && + setMemoDialog({ ...memoDialog, memo: e.target.value }) + } + onKeyDown={(e) => { + if (e.key === "Enter" && memoDialog) { + const { status, memo } = memoDialog + setMemoDialog(null) + runBulkSave(status, memo) + } + }} + /> + + + + + + + + {/* Phase 3: Undo 스낵바 */} + { + if (reason === "clickaway") return + setUndoAction(null) + }} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + sx={{ mb: checkedIds.size > 0 ? 10 : 2 }} + message={ + undoAction + ? `${undoAction.userIds.length}명에게 '${statusLabel( + undoAction.newStatus, + )}' 적용됨` + : "" + } + action={ + + } + /> + + {/* Sticky bulk action bar */} + {checkedIds.size > 0 && ( + + + + + ✓ {checkedIds.size}명 선택 + + {hiddenSelectedCount > 0 && ( + + (화면 밖 {hiddenSelectedCount}명 포함) + + )} + + + + + + + + + + + )} + + ) +} + +/* --- 하위 컴포넌트 --- */ + +function ColumnBox({ + title, + flex, + children, +}: { + title: string + flex: number + children: React.ReactNode +}) { + return ( + + + + {title} + + + {children} + + ) +} + +function RowButton({ + focused, + onClick, + children, +}: { + focused?: boolean + onClick: () => void + children: React.ReactNode +}) { + return ( + + {children} + + ) +} + +function EmptyState({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function statusLabel(status: AttendStatus) { + if (status === AttendStatus.ATTEND) return "출석" + if (status === AttendStatus.ABSENT) return "결석" + return "기타" +} diff --git a/client/src/app/admin/soon/attendance/OverviewTab.tsx b/client/src/app/admin/soon/attendance/OverviewTab.tsx new file mode 100644 index 00000000..6b5dccfc --- /dev/null +++ b/client/src/app/admin/soon/attendance/OverviewTab.tsx @@ -0,0 +1,251 @@ +"use client" + +import axios from "@/config/axios" +import { get } from "@/config/api" +import CommunityBox from "./CommunityBox" +import { User } from "@server/entity/user" +import { Stack, Box, Card, CardContent, Typography } from "@mui/material" +import { Community } from "@server/entity/community" +import { useEffect, useMemo, useState } from "react" +import { AttendData } from "@server/entity/attendData" +import { AttendStatus } from "@server/entity/types" +import { WorshipKind, WorshipSchedule } from "@server/entity/worshipSchedule" +import AttendanceTable from "./AttendanceTable" +import AttendanceFilter from "./AttendanceFilter" +import CommunityNavigation from "./CommunityNavigation" +import { sortByCommunityId, getAttendUserCount } from "./utils/attendanceUtils" +import { useNotification } from "@/hooks/useNotification" +import useAuth from "@/hooks/useAuth" + +export default function OverviewTab() { + const [communities, setCommunities] = useState([]) + const [selectedCommunity, setSelectedCommunity] = useState( + null, + ) + const [communityStack, setCommunityStack] = useState([]) + const [soonList, setSoonList] = useState([]) + const [attendDataList, setAttendDataList] = useState([]) + const [worshipScheduleFilter, setWorshipScheduleFilter] = useState< + WorshipKind | "all" + >("all") + + const { error } = useNotification() + const { authUserData } = useAuth() + const editable = Boolean( + authUserData?.role.Admin || + authUserData?.role.VillageLeader || + authUserData?.role.Leader, + ) + + useEffect(() => { + fetchCommunities() + }, []) + + async function fetchCommunities() { + const data = await get("/admin/community") + setCommunities(data) + } + + const filteredCommunities = useMemo(() => { + if (!selectedCommunity) { + return communities.filter((community) => !community.parent) + } + return communities.filter((community) => { + return community.parent?.id === selectedCommunity.id + }) + }, [communities, selectedCommunity]) + + const leaders = useMemo(() => { + return soonList.filter((user) => { + return ( + user.community?.leader?.id === user.id || + user.community?.deputyLeader?.id === user.id + ) + }) + }, [soonList]) + + useEffect(() => { + if (filteredCommunities.length === 0) { + if (!selectedCommunity) { + setSoonList([]) + setAttendDataList([]) + return + } + axios + .post("/admin/soon/get-soon-list", { + ids: selectedCommunity?.id, + }) + .then((response) => { + const soonListData = response.data as User[] + soonListData.sort(sortByCommunityId) + setSoonList(soonListData) + }) + return + } + axios + .post("/admin/soon/get-soon-list", { + ids: filteredCommunities.map((community) => community.id).join(","), + }) + .then((response) => { + const soonListData = response.data as User[] + soonListData.sort(sortByCommunityId) + setSoonList(soonListData) + }) + }, [filteredCommunities]) + + useEffect(() => { + if (soonList.length === 0) return + const soonIds = soonList.map((user) => user.id) + + axios + .post("/admin/soon/user-attendance", { + ids: soonIds.join(","), + }) + .then((response) => { + setAttendDataList(response.data) + }) + }, [soonList]) + + const worshipScheduleMapList = useMemo(() => { + const map: WorshipSchedule[] = [] + attendDataList.forEach((data) => { + const existing = map.find( + (worshipSchedule) => worshipSchedule.id === data.worshipSchedule.id, + ) + if (existing) { + return + } + if ( + worshipScheduleFilter !== "all" && + data.worshipSchedule.kind !== worshipScheduleFilter + ) { + return + } + map.push(data.worshipSchedule) + }) + return map.sort((a, b) => { + return new Date(b.date).getTime() - new Date(a.date).getTime() + }) + }, [attendDataList, worshipScheduleFilter]) + + async function handleSaveCell( + userId: string, + worshipScheduleId: number, + status: AttendStatus, + memo: string, + ) { + try { + await axios.post("/admin/soon/update-attendance", { + userId, + worshipScheduleId, + isAttend: status, + memo, + }) + + setAttendDataList((prev) => { + const idx = prev.findIndex( + (d) => + d.user.id === userId && d.worshipSchedule.id === worshipScheduleId, + ) + if (idx >= 0) { + const updated = [...prev] + updated[idx] = { ...updated[idx], isAttend: status, memo } + return updated + } + const schedule = worshipScheduleMapList.find( + (ws) => ws.id === worshipScheduleId, + ) + return [ + ...prev, + { + user: { id: userId } as User, + worshipSchedule: (schedule ?? { + id: worshipScheduleId, + }) as WorshipSchedule, + isAttend: status, + memo, + } as AttendData, + ] + }) + } catch (e: any) { + error("저장 실패: " + (e?.response?.data?.error || e?.message || "")) + } + } + + function handleCommunityClick(community: Community) { + setSelectedCommunity(community) + setCommunityStack((prev) => { + const newStack = [...prev, community] + return newStack + }) + } + + function handleBackClick() { + setCommunityStack((prev) => { + const newStack = [...prev] + newStack.pop() + return newStack + }) + setSelectedCommunity(communityStack[communityStack.length - 2] || null) + } + + return ( + + + + + + + + + + + + + + {filteredCommunities.length > 0 && ( + + + 다락방 선택 + + + {filteredCommunities.map((community) => ( + + ))} + + + )} + + + + + + + + + + + ) +} diff --git a/client/src/app/admin/soon/attendance/page.tsx b/client/src/app/admin/soon/attendance/page.tsx index 4a304947..97f911ba 100644 --- a/client/src/app/admin/soon/attendance/page.tsx +++ b/client/src/app/admin/soon/attendance/page.tsx @@ -1,204 +1,32 @@ "use client" -import axios from "@/config/axios" -import { get } from "@/config/api" -import CommunityBox from "./CommunityBox" -import { User } from "@server/entity/user" -import { Stack, Box, Card, CardContent, Typography } from "@mui/material" -import { Community } from "@server/entity/community" -import { useEffect, useMemo, useState } from "react" -import { AttendData } from "@server/entity/attendData" -import { WorshipKind, WorshipSchedule } from "@server/entity/worshipSchedule" -import AttendanceTable from "./AttendanceTable" -import AttendanceFilter from "./AttendanceFilter" -import CommunityNavigation from "./CommunityNavigation" -import { sortByCommunityId, getAttendUserCount } from "./utils/attendanceUtils" +import { useState } from "react" +import { Box, Tab, Tabs } from "@mui/material" +import OverviewTab from "./OverviewTab" +import EditTab from "./EditTab" export default function AttendanceAdminPage() { - const [communities, setCommunities] = useState([]) - const [selectedCommunity, setSelectedCommunity] = useState( - null - ) - const [communityStack, setCommunityStack] = useState([]) - const [soonList, setSoonList] = useState([]) - const [attendDataList, setAttendDataList] = useState([]) - const [worshipScheduleFilter, setWorshipScheduleFilter] = useState< - WorshipKind | "all" - >("all") - - useEffect(() => { - fetchCommunities() - }, []) - - async function fetchCommunities() { - const data = await get("/admin/community") - setCommunities(data) - } - - const filteredCommunities = useMemo(() => { - if (!selectedCommunity) { - return communities.filter((community) => !community.parent) - } - return communities.filter((community) => { - return community.parent?.id === selectedCommunity.id - }) - }, [communities, selectedCommunity]) - - const leaders = useMemo(() => { - return soonList.filter((user) => { - return ( - user.community?.leader?.id === user.id || - user.community?.deputyLeader?.id === user.id - ) - }) - }, [soonList]) - - useEffect(() => { - if (filteredCommunities.length === 0) { - if (!selectedCommunity) { - setSoonList([]) - setAttendDataList([]) - return - } - axios - .post("/admin/soon/get-soon-list", { - ids: selectedCommunity?.id, - }) - .then((response) => { - const soonListData = response.data as User[] - soonListData.sort(sortByCommunityId) - setSoonList(soonListData) - }) - return - } - axios - .post("/admin/soon/get-soon-list", { - ids: filteredCommunities.map((community) => community.id).join(","), - }) - .then((response) => { - const soonListData = response.data as User[] - soonListData.sort(sortByCommunityId) - setSoonList(soonListData) - }) - }, [filteredCommunities]) - - useEffect(() => { - if (soonList.length === 0) return - const soonIds = soonList.map((user) => user.id) - - axios - .post("/admin/soon/user-attendance", { - ids: soonIds.join(","), - }) - .then((response) => { - setAttendDataList(response.data) - }) - }, [soonList]) - - const worshipScheduleMapList = useMemo(() => { - const map: WorshipSchedule[] = [] - attendDataList.forEach((data) => { - const existing = map.find( - (worshipSchedule) => worshipSchedule.id === data.worshipSchedule.id - ) - if (existing) { - return - } - if ( - worshipScheduleFilter !== "all" && - data.worshipSchedule.kind !== worshipScheduleFilter - ) { - return - } - map.push(data.worshipSchedule) - }) - return map.sort((a, b) => { - return new Date(b.date).getTime() - new Date(a.date).getTime() - }) - }, [attendDataList, worshipScheduleFilter]) - - function handleCommunityClick(community: Community) { - setSelectedCommunity(community) - setCommunityStack((prev) => { - const newStack = [...prev, community] - return newStack - }) - } - - function handleBackClick() { - setCommunityStack((prev) => { - const newStack = [...prev] - newStack.pop() - return newStack - }) - setSelectedCommunity(communityStack[communityStack.length - 2] || null) - } + const [tab, setTab] = useState(0) return ( - - {/* 커뮤니티 네비게이션 & 필터 통합 카드 */} - - - - {/* 상단: 네비게이션과 필터 */} - - - - - - - - - - {/* 하단: 다락방 선택 */} - {filteredCommunities.length > 0 && ( - - - 다락방 선택 - - - {filteredCommunities.map((community) => ( - - ))} - - - )} - - - - - {/* 출석 테이블 카드 */} - - - - - + + setTab(v)}> + + + + {tab === 0 && } + {tab === 1 && } ) } diff --git a/client/src/app/leader/all-attendance/page.tsx b/client/src/app/leader/all-attendance/page.tsx index d27947c7..141a490e 100644 --- a/client/src/app/leader/all-attendance/page.tsx +++ b/client/src/app/leader/all-attendance/page.tsx @@ -7,6 +7,7 @@ import { Stack, Box, Card, CardContent, Typography } from "@mui/material" import { Community } from "@server/entity/community" import { useEffect, useMemo, useState } from "react" import { AttendData } from "@server/entity/attendData" +import { AttendStatus } from "@server/entity/types" import { WorshipKind, WorshipSchedule } from "@server/entity/worshipSchedule" import AttendanceTable from "@/app/admin/soon/attendance/AttendanceTable" import AttendanceFilter from "@/app/admin/soon/attendance/AttendanceFilter" @@ -35,14 +36,21 @@ export default function AttendanceAdminPage() { const { authUserData } = useAuth() const { push } = useRouter() const { error } = useNotification() + const editable = Boolean( + authUserData?.role.Admin || + authUserData?.role.VillageLeader || + authUserData?.role.Leader, + ) useEffect(() => { + // authUserData가 비동기로 로드되므로 준비될 때까지 판정 보류 + if (!authUserData) return fetchCommunities() - if (!authUserData?.role.VillageLeader) { + if (!authUserData.role.VillageLeader) { error("접근 권한이 없습니다.") push("/leader") } - }, []) + }, [authUserData]) async function fetchCommunities() { const data = await get("/admin/community") @@ -131,6 +139,50 @@ export default function AttendanceAdminPage() { }) }, [attendDataList, worshipScheduleFilter]) + async function handleSaveCell( + userId: string, + worshipScheduleId: number, + status: AttendStatus, + memo: string, + ) { + try { + await axios.post("/admin/soon/update-attendance", { + userId, + worshipScheduleId, + isAttend: status, + memo, + }) + + setAttendDataList((prev) => { + const idx = prev.findIndex( + (d) => + d.user.id === userId && d.worshipSchedule.id === worshipScheduleId, + ) + if (idx >= 0) { + const updated = [...prev] + updated[idx] = { ...updated[idx], isAttend: status, memo } + return updated + } + const schedule = worshipScheduleMapList.find( + (ws) => ws.id === worshipScheduleId, + ) + return [ + ...prev, + { + user: { id: userId } as User, + worshipSchedule: (schedule ?? { + id: worshipScheduleId, + }) as WorshipSchedule, + isAttend: status, + memo, + } as AttendData, + ] + }) + } catch (e: any) { + error("저장 실패: " + (e?.response?.data?.error || e?.message || "")) + } + } + function handleCommunityClick(community: Community) { setSelectedCommunity(community) setCommunityStack((prev) => { @@ -209,6 +261,8 @@ export default function AttendanceAdminPage() { worshipScheduleMapList={worshipScheduleMapList} leaders={leaders} getAttendUserCount={getAttendUserCount} + editable={editable} + onSaveCell={handleSaveCell} /> diff --git a/server/src/routes/admin/soonRouter.ts b/server/src/routes/admin/soonRouter.ts index 4a47c651..5cad007b 100644 --- a/server/src/routes/admin/soonRouter.ts +++ b/server/src/routes/admin/soonRouter.ts @@ -1,5 +1,5 @@ import express from "express" -import { hasPermission } from "../../util/util" +import { checkJwt, hasPermission } from "../../util/util" import { PermissionType } from "../../entity/types" import { attendDataDatabase, @@ -8,6 +8,7 @@ import { } from "../../model/dataSource" import { In, Not, IsNull } from "typeorm" import _ from "lodash" +import { jwtPayload } from "../../util/type" const router = express.Router() @@ -186,4 +187,90 @@ router.post("/user-attendance", async (req, res) => { res.status(200).send(attendDataList) }) +async function isInSubtree( + ancestorId: number | undefined, + targetId: number | undefined, +): Promise { + if (!ancestorId || !targetId) return false + if (ancestorId === targetId) return true + + const all = await communityDatabase.find({ relations: { children: true } }) + const visited = new Set() + + function walk(id: number): boolean { + if (visited.has(id)) return false + visited.add(id) + if (id === targetId) return true + const node = all.find((c) => c.id === id) + if (!node) return false + return node.children.some((child) => walk(child.id)) + } + return walk(ancestorId) +} + +async function canEditUserAttendance( + requester: jwtPayload, + targetUserId: string, +): Promise { + // Admin은 어느 유저든 편집 가능 + if (requester.role.Admin) return true + + // 출석 관리 권한이 있는 역할: Leader (자기 다락방) 또는 VillageLeader (마을 트리 하위 전체) + if (!requester.role.Leader && !requester.role.VillageLeader) return false + + const target = await userDatabase.findOne({ + where: { id: targetUserId }, + relations: { community: true }, + }) + if (!target?.community) return false + + // 대상 유저의 community가 내 community의 하위(또는 동일)인지 + return await isInSubtree(requester.community?.id, target.community.id) +} + +router.post("/update-attendance", async (req, res) => { + const jwt = await checkJwt(req) + if (!jwt) { + res.status(401).send({ error: "Unauthorized" }) + return + } + + const { userId, worshipScheduleId, isAttend, memo } = req.body + if (!userId || !worshipScheduleId || !isAttend) { + res.status(400).send({ error: "Missing required fields" }) + return + } + + const allowed = await canEditUserAttendance(jwt, userId) + if (!allowed) { + res.status(403).send({ error: "해당 유저의 출석을 편집할 권한이 없습니다." }) + return + } + + const existing = await attendDataDatabase.findOne({ + where: { + user: { id: userId }, + worshipSchedule: { id: worshipScheduleId }, + }, + }) + + if (existing) { + existing.isAttend = isAttend + existing.memo = memo || "" + await attendDataDatabase.save(existing) + res.send({ result: "success" }) + return + } + + await attendDataDatabase.save( + attendDataDatabase.create({ + user: { id: userId }, + worshipSchedule: { id: worshipScheduleId }, + isAttend, + memo: memo || "", + }), + ) + res.send({ result: "success" }) +}) + export default router From 7a908bc93822eb7e1841b041e459fb1bf94b2246 Mon Sep 17 00:00:00 2001 From: Kim Minhyeok Date: Fri, 24 Apr 2026 00:07:29 +0900 Subject: [PATCH 02/14] =?UTF-8?q?update:=20=EC=B6=9C=EC=84=9D=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 필터 chip 가로 스크롤 (한 줄, 페이드 힌트) - iPhone safe-area inset 적용 (bulk bar, snackbar, 스크롤 여백) - 예배 select를 OS 네이티브 picker로 (iOS 휠 / Android 다이얼로그) - 예배/필터/검색 헤더 sticky (리스트 스크롤해도 상단 고정) - 상태 chip 말줄임 + hover tooltip (긴 memo 레이아웃 보호) - bulk 버튼 아이콘화 (600px 미만에선 아이콘만, 이상은 텍스트+아이콘) - AttendanceTable hydration mismatch 수정 (global.innerWidth → useMediaQuery) --- .../admin/soon/attendance/AttendanceTable.tsx | 14 +- .../src/app/admin/soon/attendance/EditTab.tsx | 158 ++++++++++++++---- 2 files changed, 135 insertions(+), 37 deletions(-) diff --git a/client/src/app/admin/soon/attendance/AttendanceTable.tsx b/client/src/app/admin/soon/attendance/AttendanceTable.tsx index 545b43cd..b95087e8 100644 --- a/client/src/app/admin/soon/attendance/AttendanceTable.tsx +++ b/client/src/app/admin/soon/attendance/AttendanceTable.tsx @@ -1,4 +1,12 @@ -import { Box, Stack, Typography, Paper, Chip } from "@mui/material" +import { + Box, + Stack, + Typography, + Paper, + Chip, + useMediaQuery, + useTheme, +} from "@mui/material" import { User } from "@server/entity/user" import { AttendData } from "@server/entity/attendData" import { WorshipSchedule } from "@server/entity/worshipSchedule" @@ -34,7 +42,9 @@ export default function AttendanceTable({ editable = false, onSaveCell, }: AttendanceTableProps) { - const isMobile = global.innerWidth < 600 + const theme = useTheme() + // SSR-safe: 서버 렌더 시엔 false, 마운트 후 실제 window 크기로 재계산 + const isMobile = useMediaQuery(theme.breakpoints.down("sm")) return ( {/* 출석 테이블 제목 */} diff --git a/client/src/app/admin/soon/attendance/EditTab.tsx b/client/src/app/admin/soon/attendance/EditTab.tsx index 1058ab43..cb0a7ea0 100644 --- a/client/src/app/admin/soon/attendance/EditTab.tsx +++ b/client/src/app/admin/soon/attendance/EditTab.tsx @@ -29,6 +29,7 @@ import ArrowBackIcon from "@mui/icons-material/ArrowBack" import CheckCircleIcon from "@mui/icons-material/CheckCircle" import CancelIcon from "@mui/icons-material/Cancel" import HelpIcon from "@mui/icons-material/Help" +import CloseIcon from "@mui/icons-material/Close" import axios from "@/config/axios" import { get } from "@/config/api" @@ -74,6 +75,8 @@ export default function EditTab() { const theme = useTheme() const isMobile = useMediaQuery(theme.breakpoints.down("md")) + // bulk 버튼 라벨 숨김 기준 — 600px 미만에선 아이콘만 + const isNarrow = useMediaQuery(theme.breakpoints.down("sm")) // 초기 로드 useEffect(() => { @@ -450,30 +453,48 @@ export default function EditTab() { } function statusChip(status: StatusFilter, memo?: string) { + // chip 공통 style — 긴 memo는 말줄임 처리 + const base = { + fontWeight: 600, + maxWidth: { xs: 140, sm: 200, md: 260 }, + // MUI Chip 기본 label엔 이미 ellipsis가 걸려있지만, 명시적으로 한 번 더 + "& .MuiChip-label": { + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap", + }, + } if (status === "ATTEND") return ( ) - if (status === "ABSENT") + if (status === "ABSENT") { + const full = memo ? `결석 · ${memo}` : "결석" return ( ) - if (status === "ETC") + } + if (status === "ETC") { + const full = memo ? `기타 · ${memo}` : "기타" return ( ) + } return null } @@ -495,31 +516,79 @@ export default function EditTab() { } return ( - - {/* 예배 선택 */} + + {/* 컨트롤 3종을 sticky 래퍼로 묶어 리스트 스크롤 시에도 상단 고정 */} + + {/* 예배 선택 — 모바일: OS 네이티브 picker (iOS 휠, Android 다이얼로그) */} - - 예배 - - + + setSelectedScheduleId(Number(e.target.value) || "") + } + InputLabelProps={{ shrink: true }} + > + {schedules.map((s) => ( + + ))} + - {/* 상태 필터 */} + {/* 상태 필터 — 모바일 친화적 가로 스크롤 */} 상태별 필터 - + *": { flexShrink: 0 }, + // 스크롤바 숨김 (가독성) + "&::-webkit-scrollbar": { display: "none" }, + scrollbarWidth: "none", + // iOS 모멘텀 스크롤 + WebkitOverflowScrolling: "touch", + // 오른쪽 가장자리 페이드 힌트 (스크롤 가능 암시) + WebkitMaskImage: + "linear-gradient(to right, black calc(100% - 24px), transparent)", + maskImage: + "linear-gradient(to right, black calc(100% - 24px), transparent)", + pr: 3, // 페이드 영역 너비만큼 여유 + pb: 0.5, + }} + > {( [ { k: "all", label: "전체", count: counts.all }, @@ -538,7 +607,7 @@ export default function EditTab() { disabled={k !== "all" && count === 0} /> ))} - + {/* 검색 */} @@ -551,6 +620,8 @@ export default function EditTab() { onChange={(e) => setSearchText(e.target.value)} /> + + {/* ↑ sticky 래퍼 종료 */} {/* 3-column 리스트 */} @@ -895,7 +966,13 @@ export default function EditTab() { setUndoAction(null) }} anchorOrigin={{ vertical: "bottom", horizontal: "center" }} - sx={{ mb: checkedIds.size > 0 ? 10 : 2 }} + sx={{ + // bulk bar 위로 / 없으면 최소 여백. 모두 safe-area inset 추가 + mb: + checkedIds.size > 0 + ? "calc(80px + env(safe-area-inset-bottom, 0px))" + : "calc(16px + env(safe-area-inset-bottom, 0px))", + }} message={ undoAction ? `${undoAction.userIds.length}명에게 '${statusLabel( @@ -919,7 +996,10 @@ export default function EditTab() { bottom: 0, left: 0, right: 0, - p: 1.5, + pt: 1.5, + px: 1.5, + // 하단 padding에 iPhone 홈 인디케이터 인셋 포함 + pb: "calc(12px + env(safe-area-inset-bottom, 0px))", zIndex: 100, borderTop: "2px solid #1976d2", }} @@ -944,40 +1024,48 @@ export default function EditTab() { From c702b2ad767f275b09d6d3d7c454d67740a09ef3 Mon Sep 17 00:00:00 2001 From: Kim Minhyeok Date: Fri, 24 Apr 2026 13:03:49 +0900 Subject: [PATCH 03/14] =?UTF-8?q?refactor:=20EditTab=20=EB=82=B4=20?= =?UTF-8?q?=EC=88=9C=EC=88=98=20=ED=95=A8=EC=88=98=20=EC=9C=84=EC=B9=98=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit statusLabel을 파일 하단에서 하위 컴포넌트 영역 위로 이동하여 사용처와의 거리를 줄였습니다. 자명한 주석 1줄만 제거하고 WHY를 설명하는 주석은 유지했습니다. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/src/app/admin/soon/attendance/EditTab.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/client/src/app/admin/soon/attendance/EditTab.tsx b/client/src/app/admin/soon/attendance/EditTab.tsx index cb0a7ea0..a62bb2e8 100644 --- a/client/src/app/admin/soon/attendance/EditTab.tsx +++ b/client/src/app/admin/soon/attendance/EditTab.tsx @@ -453,7 +453,6 @@ export default function EditTab() { } function statusChip(status: StatusFilter, memo?: string) { - // chip 공통 style — 긴 memo는 말줄임 처리 const base = { fontWeight: 600, maxWidth: { xs: 140, sm: 200, md: 260 }, @@ -1075,6 +1074,12 @@ export default function EditTab() { ) } +function statusLabel(status: AttendStatus) { + if (status === AttendStatus.ATTEND) return "출석" + if (status === AttendStatus.ABSENT) return "결석" + return "기타" +} + /* --- 하위 컴포넌트 --- */ function ColumnBox({ @@ -1152,9 +1157,3 @@ function EmptyState({ children }: { children: React.ReactNode }) { ) } - -function statusLabel(status: AttendStatus) { - if (status === AttendStatus.ATTEND) return "출석" - if (status === AttendStatus.ABSENT) return "결석" - return "기타" -} From c6ff9f5590042d714250cd761221746a8025b15c Mon Sep 17 00:00:00 2001 From: Kim Minhyeok Date: Fri, 24 Apr 2026 13:03:55 +0900 Subject: [PATCH 04/14] =?UTF-8?q?update:=20=EA=B6=8C=ED=95=9C=20=EB=B6=80?= =?UTF-8?q?=EC=A1=B1=20=EC=9D=91=EB=8B=B5=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=95=20(=EC=A0=95=EB=B3=B4=20=EB=85=B8=EC=B6=9C?= =?UTF-8?q?=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 403 응답 본문을 한국어 상세 설명에서 "Forbidden"으로 단축하여 엔드포인트의 권한 체크 로직이 외부로 추정되지 않도록 했습니다. 동일 엔드포인트의 401/400 응답 스타일과도 일관됩니다. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/routes/admin/soonRouter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/routes/admin/soonRouter.ts b/server/src/routes/admin/soonRouter.ts index 5cad007b..edc24a97 100644 --- a/server/src/routes/admin/soonRouter.ts +++ b/server/src/routes/admin/soonRouter.ts @@ -243,7 +243,7 @@ router.post("/update-attendance", async (req, res) => { const allowed = await canEditUserAttendance(jwt, userId) if (!allowed) { - res.status(403).send({ error: "해당 유저의 출석을 편집할 권한이 없습니다." }) + res.status(403).send({ error: "Forbidden" }) return } From 3fe792c584c8f091c9b56e99ba94778797e42f0d Mon Sep 17 00:00:00 2001 From: Kim Minhyeok Date: Fri, 24 Apr 2026 13:08:14 +0900 Subject: [PATCH 05/14] =?UTF-8?q?update:=20/update-attendance=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EA=B2=80=EC=A6=9D=20=EA=B0=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isAttend가 AttendStatus enum 값인지 확인하고, memo의 타입과 길이(500자 이내)를 검증하여 데이터 무결성 훼손과 과대 입력을 방어합니다. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/routes/admin/soonRouter.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/server/src/routes/admin/soonRouter.ts b/server/src/routes/admin/soonRouter.ts index edc24a97..949bd291 100644 --- a/server/src/routes/admin/soonRouter.ts +++ b/server/src/routes/admin/soonRouter.ts @@ -1,6 +1,6 @@ import express from "express" import { checkJwt, hasPermission } from "../../util/util" -import { PermissionType } from "../../entity/types" +import { AttendStatus, PermissionType } from "../../entity/types" import { attendDataDatabase, communityDatabase, @@ -241,6 +241,22 @@ router.post("/update-attendance", async (req, res) => { return } + if (!(Object.values(AttendStatus) as string[]).includes(isAttend)) { + res.status(400).send({ error: "Invalid isAttend value" }) + return + } + + if (memo !== undefined && memo !== null) { + if (typeof memo !== "string") { + res.status(400).send({ error: "Invalid memo type" }) + return + } + if (memo.length > 500) { + res.status(400).send({ error: "Memo too long" }) + return + } + } + const allowed = await canEditUserAttendance(jwt, userId) if (!allowed) { res.status(403).send({ error: "Forbidden" }) From 140a368372eaf0d937e2da44331fd5606d7206b9 Mon Sep 17 00:00:00 2001 From: Kim Minhyeok Date: Fri, 24 Apr 2026 13:31:03 +0900 Subject: [PATCH 06/14] =?UTF-8?q?refactor:=20editable=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=8F=84=EB=8B=AC=20=EB=B6=88=EA=B0=80?= =?UTF-8?q?=EB=8A=A5=ED=95=9C=20Leader=20=EC=A1=B0=EA=B1=B4=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 페이지 접근 가드가 VillageLeader만 허용하므로 editable에 포함된 role.Leader는 실행에 도달하지 않는 죽은 조건입니다. 의도와 실제를 일치시키기 위해 제거했습니다. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/src/app/leader/all-attendance/page.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/src/app/leader/all-attendance/page.tsx b/client/src/app/leader/all-attendance/page.tsx index 141a490e..58c28b59 100644 --- a/client/src/app/leader/all-attendance/page.tsx +++ b/client/src/app/leader/all-attendance/page.tsx @@ -37,9 +37,7 @@ export default function AttendanceAdminPage() { const { push } = useRouter() const { error } = useNotification() const editable = Boolean( - authUserData?.role.Admin || - authUserData?.role.VillageLeader || - authUserData?.role.Leader, + authUserData?.role.Admin || authUserData?.role.VillageLeader, ) useEffect(() => { From a0f6a203cb28db98d7c7ba12221407a378ac9429 Mon Sep 17 00:00:00 2001 From: Kim Minhyeok Date: Fri, 24 Apr 2026 13:40:32 +0900 Subject: [PATCH 07/14] =?UTF-8?q?remove:=20/leader/all-attendance=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=B6=9C=EC=84=9D=20=ED=8E=B8=EC=A7=91=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 출석 편집은 /admin/soon/attendance 에서만 가능하도록 하기 위해 leader 페이지에 추가했던 editable 변수, handleSaveCell 핸들러, AttendanceTable의 편집 props를 걷어냈습니다. authUserData 로딩 타이밍 수정은 원래 있던 버그 수정이므로 유지합니다. Co-Authored-By: Claude Opus 4.7 (1M context) --- client/src/app/leader/all-attendance/page.tsx | 50 ------------------- 1 file changed, 50 deletions(-) diff --git a/client/src/app/leader/all-attendance/page.tsx b/client/src/app/leader/all-attendance/page.tsx index 58c28b59..424ba848 100644 --- a/client/src/app/leader/all-attendance/page.tsx +++ b/client/src/app/leader/all-attendance/page.tsx @@ -7,7 +7,6 @@ import { Stack, Box, Card, CardContent, Typography } from "@mui/material" import { Community } from "@server/entity/community" import { useEffect, useMemo, useState } from "react" import { AttendData } from "@server/entity/attendData" -import { AttendStatus } from "@server/entity/types" import { WorshipKind, WorshipSchedule } from "@server/entity/worshipSchedule" import AttendanceTable from "@/app/admin/soon/attendance/AttendanceTable" import AttendanceFilter from "@/app/admin/soon/attendance/AttendanceFilter" @@ -36,9 +35,6 @@ export default function AttendanceAdminPage() { const { authUserData } = useAuth() const { push } = useRouter() const { error } = useNotification() - const editable = Boolean( - authUserData?.role.Admin || authUserData?.role.VillageLeader, - ) useEffect(() => { // authUserData가 비동기로 로드되므로 준비될 때까지 판정 보류 @@ -137,50 +133,6 @@ export default function AttendanceAdminPage() { }) }, [attendDataList, worshipScheduleFilter]) - async function handleSaveCell( - userId: string, - worshipScheduleId: number, - status: AttendStatus, - memo: string, - ) { - try { - await axios.post("/admin/soon/update-attendance", { - userId, - worshipScheduleId, - isAttend: status, - memo, - }) - - setAttendDataList((prev) => { - const idx = prev.findIndex( - (d) => - d.user.id === userId && d.worshipSchedule.id === worshipScheduleId, - ) - if (idx >= 0) { - const updated = [...prev] - updated[idx] = { ...updated[idx], isAttend: status, memo } - return updated - } - const schedule = worshipScheduleMapList.find( - (ws) => ws.id === worshipScheduleId, - ) - return [ - ...prev, - { - user: { id: userId } as User, - worshipSchedule: (schedule ?? { - id: worshipScheduleId, - }) as WorshipSchedule, - isAttend: status, - memo, - } as AttendData, - ] - }) - } catch (e: any) { - error("저장 실패: " + (e?.response?.data?.error || e?.message || "")) - } - } - function handleCommunityClick(community: Community) { setSelectedCommunity(community) setCommunityStack((prev) => { @@ -259,8 +211,6 @@ export default function AttendanceAdminPage() { worshipScheduleMapList={worshipScheduleMapList} leaders={leaders} getAttendUserCount={getAttendUserCount} - editable={editable} - onSaveCell={handleSaveCell} /> From c56468e19ad3ab78f77c23ebb8a1c16ba8aea4ac Mon Sep 17 00:00:00 2001 From: Kim Minhyeok Date: Fri, 24 Apr 2026 13:50:25 +0900 Subject: [PATCH 08/14] =?UTF-8?q?refactor:=20=EC=B6=9C=EC=84=9D=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91=20=EA=B6=8C=ED=95=9C=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=EC=9D=84=20model=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20L?= =?UTF-8?q?eader=20=EB=B2=94=EC=9C=84=20=EB=AA=85=ED=99=95=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - canEditUserAttendance, isInSubtree를 server/src/model/attendance.ts로 이동 (라우터 슬림화) - Leader는 자기 community만, VillageLeader는 subtree 전체로 권한 범위를 명시적으로 분리 (기존엔 둘 다 subtree 체크) - isInSubtree에 id→node Map을 추가해 탐색을 O(1)로 개선 - memo 미전송 시 기존 memo가 빈 문자열로 덮어써지던 문제를 조건부 업데이트로 수정 Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/model/attendance.ts | 47 +++++++++++++++++++++++++ server/src/routes/admin/soonRouter.ts | 49 +++------------------------ 2 files changed, 52 insertions(+), 44 deletions(-) create mode 100644 server/src/model/attendance.ts diff --git a/server/src/model/attendance.ts b/server/src/model/attendance.ts new file mode 100644 index 00000000..1e640d29 --- /dev/null +++ b/server/src/model/attendance.ts @@ -0,0 +1,47 @@ +import { jwtPayload } from "../util/type" +import { communityDatabase, userDatabase } from "./dataSource" + +async function isInSubtree( + ancestorId: number | undefined, + targetId: number | undefined, +): Promise { + if (!ancestorId || !targetId) return false + if (ancestorId === targetId) return true + + const all = await communityDatabase.find({ relations: { children: true } }) + const byId = new Map(all.map((c) => [c.id, c])) + const visited = new Set() + + function walk(id: number): boolean { + if (visited.has(id)) return false + visited.add(id) + if (id === targetId) return true + const node = byId.get(id) + if (!node) return false + return node.children.some((child) => walk(child.id)) + } + return walk(ancestorId) +} + +export async function canEditUserAttendance( + requester: jwtPayload, + targetUserId: string, +): Promise { + if (requester.role.Admin) return true + + const target = await userDatabase.findOne({ + where: { id: targetUserId }, + relations: { community: true }, + }) + if (!target?.community) return false + + if (requester.role.Leader) { + return requester.community?.id === target.community.id + } + + if (requester.role.VillageLeader) { + return isInSubtree(requester.community?.id, target.community.id) + } + + return false +} diff --git a/server/src/routes/admin/soonRouter.ts b/server/src/routes/admin/soonRouter.ts index 949bd291..7c8c3b94 100644 --- a/server/src/routes/admin/soonRouter.ts +++ b/server/src/routes/admin/soonRouter.ts @@ -6,9 +6,9 @@ import { communityDatabase, userDatabase, } from "../../model/dataSource" +import { canEditUserAttendance } from "../../model/attendance" import { In, Not, IsNull } from "typeorm" import _ from "lodash" -import { jwtPayload } from "../../util/type" const router = express.Router() @@ -187,47 +187,6 @@ router.post("/user-attendance", async (req, res) => { res.status(200).send(attendDataList) }) -async function isInSubtree( - ancestorId: number | undefined, - targetId: number | undefined, -): Promise { - if (!ancestorId || !targetId) return false - if (ancestorId === targetId) return true - - const all = await communityDatabase.find({ relations: { children: true } }) - const visited = new Set() - - function walk(id: number): boolean { - if (visited.has(id)) return false - visited.add(id) - if (id === targetId) return true - const node = all.find((c) => c.id === id) - if (!node) return false - return node.children.some((child) => walk(child.id)) - } - return walk(ancestorId) -} - -async function canEditUserAttendance( - requester: jwtPayload, - targetUserId: string, -): Promise { - // Admin은 어느 유저든 편집 가능 - if (requester.role.Admin) return true - - // 출석 관리 권한이 있는 역할: Leader (자기 다락방) 또는 VillageLeader (마을 트리 하위 전체) - if (!requester.role.Leader && !requester.role.VillageLeader) return false - - const target = await userDatabase.findOne({ - where: { id: targetUserId }, - relations: { community: true }, - }) - if (!target?.community) return false - - // 대상 유저의 community가 내 community의 하위(또는 동일)인지 - return await isInSubtree(requester.community?.id, target.community.id) -} - router.post("/update-attendance", async (req, res) => { const jwt = await checkJwt(req) if (!jwt) { @@ -272,7 +231,9 @@ router.post("/update-attendance", async (req, res) => { if (existing) { existing.isAttend = isAttend - existing.memo = memo || "" + if (typeof memo === "string") { + existing.memo = memo + } await attendDataDatabase.save(existing) res.send({ result: "success" }) return @@ -283,7 +244,7 @@ router.post("/update-attendance", async (req, res) => { user: { id: userId }, worshipSchedule: { id: worshipScheduleId }, isAttend, - memo: memo || "", + memo: typeof memo === "string" ? memo : "", }), ) res.send({ result: "success" }) From c657935e634aff1d19c3e4ec4ec1772a72c10589 Mon Sep 17 00:00:00 2001 From: Kim Minhyeok Date: Fri, 24 Apr 2026 13:50:31 +0900 Subject: [PATCH 09/14] =?UTF-8?q?fix:=20=ED=81=B4=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20use?= =?UTF-8?q?=20client=20=EC=A7=80=EC=8B=9C=EC=96=B4=20=EB=B0=8F=20leader=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B6=8C=ED=95=9C=20=EC=B2=B4?= =?UTF-8?q?=ED=81=AC=20=EC=88=9C=EC=84=9C=20=EA=B5=90=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AttendCell(useState/Popover), AttendanceTable(useTheme/useMediaQuery)에 "use client" 지시어를 명시해 Next.js App Router 경계 규약을 충족 - /leader/all-attendance 에서 fetchCommunities 호출을 VillageLeader 체크 이후로 이동해 비권한 사용자가 불필요한 /admin/community 요청을 내지 않도록 함 Co-Authored-By: Claude Opus 4.7 (1M context) --- client/src/app/admin/soon/attendance/AttendCell.tsx | 2 ++ client/src/app/admin/soon/attendance/AttendanceTable.tsx | 2 ++ client/src/app/leader/all-attendance/page.tsx | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/client/src/app/admin/soon/attendance/AttendCell.tsx b/client/src/app/admin/soon/attendance/AttendCell.tsx index 21bd9cbe..fc02c7ba 100644 --- a/client/src/app/admin/soon/attendance/AttendCell.tsx +++ b/client/src/app/admin/soon/attendance/AttendCell.tsx @@ -1,3 +1,5 @@ +"use client" + import { useState } from "react" import { Box, diff --git a/client/src/app/admin/soon/attendance/AttendanceTable.tsx b/client/src/app/admin/soon/attendance/AttendanceTable.tsx index b95087e8..827939ea 100644 --- a/client/src/app/admin/soon/attendance/AttendanceTable.tsx +++ b/client/src/app/admin/soon/attendance/AttendanceTable.tsx @@ -1,3 +1,5 @@ +"use client" + import { Box, Stack, diff --git a/client/src/app/leader/all-attendance/page.tsx b/client/src/app/leader/all-attendance/page.tsx index 424ba848..eb7951b1 100644 --- a/client/src/app/leader/all-attendance/page.tsx +++ b/client/src/app/leader/all-attendance/page.tsx @@ -39,11 +39,12 @@ export default function AttendanceAdminPage() { useEffect(() => { // authUserData가 비동기로 로드되므로 준비될 때까지 판정 보류 if (!authUserData) return - fetchCommunities() if (!authUserData.role.VillageLeader) { error("접근 권한이 없습니다.") push("/leader") + return } + fetchCommunities() }, [authUserData]) async function fetchCommunities() { From 6085986aaa69f2686dc438d40ef1417fbfde6bcc Mon Sep 17 00:00:00 2001 From: Kim Minhyeok Date: Fri, 24 Apr 2026 13:50:40 +0900 Subject: [PATCH 10/14] =?UTF-8?q?update:=20=EC=B6=9C=EC=84=9D=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20UX/=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0=20(?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EB=B2=88=EC=97=AD,=20=EB=B0=B0=EC=B9=98?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5,=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20import?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client/src/util/attendanceError.ts 추가: 서버 에러 코드를 한국어 메시지로 매핑하는 유틸 - OverviewTab, EditTab의 에러 처리에서 이 유틸을 사용해 "Forbidden" 같은 서버 원문 대신 사용자 친화적 문구를 노출 - EditTab의 runBulkSave를 10개씩 배치로 Promise.allSettled 처리해 대량 선택 시 서버 과부하 및 브라우저 연결 한계를 완화 - 실패 발생 시 실패 건수뿐 아니라 첫 실패의 원인을 함께 표시 - EditTab에서 미사용 MUI 컴포넌트(Select/MenuItem/FormControl/InputLabel) import 제거 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/app/admin/soon/attendance/EditTab.tsx | 41 ++++++++++++------- .../app/admin/soon/attendance/OverviewTab.tsx | 5 ++- client/src/util/attendanceError.ts | 15 +++++++ 3 files changed, 44 insertions(+), 17 deletions(-) create mode 100644 client/src/util/attendanceError.ts diff --git a/client/src/app/admin/soon/attendance/EditTab.tsx b/client/src/app/admin/soon/attendance/EditTab.tsx index a62bb2e8..daab3970 100644 --- a/client/src/app/admin/soon/attendance/EditTab.tsx +++ b/client/src/app/admin/soon/attendance/EditTab.tsx @@ -5,10 +5,6 @@ import { Box, Stack, Typography, - Select, - MenuItem, - FormControl, - InputLabel, TextField, Checkbox, Button, @@ -39,6 +35,7 @@ import { Community } from "@server/entity/community" import { User } from "@server/entity/user" import { WorshipSchedule } from "@server/entity/worshipSchedule" import { worshipKr } from "@/util/worship" +import { toAttendanceErrorMessage } from "@/util/attendanceError" import { useNotification } from "@/hooks/useNotification" type StatusFilter = "all" | "unrecorded" | "ATTEND" | "ABSENT" | "ETC" @@ -337,16 +334,22 @@ export default function EditTab() { }) setSaving(true) - const results = await Promise.allSettled( - ids.map((userId) => - axios.post("/admin/soon/update-attendance", { - userId, - worshipScheduleId: selectedScheduleId, - isAttend: status, - memo, - }), - ), - ) + const BULK_SAVE_BATCH_SIZE = 10 + const results: PromiseSettledResult[] = [] + for (let i = 0; i < ids.length; i += BULK_SAVE_BATCH_SIZE) { + const batchIds = ids.slice(i, i + BULK_SAVE_BATCH_SIZE) + const batchResults = await Promise.allSettled( + batchIds.map((userId) => + axios.post("/admin/soon/update-attendance", { + userId, + worshipScheduleId: selectedScheduleId, + isAttend: status, + memo, + }), + ), + ) + results.push(...batchResults) + } const successfulIds: string[] = [] ids.forEach((id, i) => { if (results[i].status === "fulfilled") successfulIds.push(id) @@ -376,7 +379,15 @@ export default function EditTab() { setSaving(false) const failed = ids.length - successfulIds.length if (failed > 0) { - error(`${failed}건 저장 실패`) + const firstFailure = results.find( + (r) => r.status === "rejected", + ) as PromiseRejectedResult | undefined + const reason = firstFailure + ? toAttendanceErrorMessage(firstFailure.reason) + : "" + error( + reason ? `${failed}건 저장 실패 — ${reason}` : `${failed}건 저장 실패`, + ) } // Phase 3: Undo 액션 준비 (성공한 것만) diff --git a/client/src/app/admin/soon/attendance/OverviewTab.tsx b/client/src/app/admin/soon/attendance/OverviewTab.tsx index 6b5dccfc..8df78ab0 100644 --- a/client/src/app/admin/soon/attendance/OverviewTab.tsx +++ b/client/src/app/admin/soon/attendance/OverviewTab.tsx @@ -16,6 +16,7 @@ import CommunityNavigation from "./CommunityNavigation" import { sortByCommunityId, getAttendUserCount } from "./utils/attendanceUtils" import { useNotification } from "@/hooks/useNotification" import useAuth from "@/hooks/useAuth" +import { toAttendanceErrorMessage } from "@/util/attendanceError" export default function OverviewTab() { const [communities, setCommunities] = useState([]) @@ -167,8 +168,8 @@ export default function OverviewTab() { } as AttendData, ] }) - } catch (e: any) { - error("저장 실패: " + (e?.response?.data?.error || e?.message || "")) + } catch (e) { + error(toAttendanceErrorMessage(e)) } } diff --git a/client/src/util/attendanceError.ts b/client/src/util/attendanceError.ts new file mode 100644 index 00000000..b241f1d6 --- /dev/null +++ b/client/src/util/attendanceError.ts @@ -0,0 +1,15 @@ +const SERVER_ERROR_MAP: Record = { + Unauthorized: "로그인이 필요합니다.", + Forbidden: "해당 유저의 출석을 편집할 권한이 없습니다.", + "Missing required fields": "필수 정보가 누락되었습니다.", + "Invalid isAttend value": "잘못된 출석 값입니다.", + "Invalid memo type": "메모 형식이 올바르지 않습니다.", + "Memo too long": "메모는 500자 이내로 작성해주세요.", +} + +export function toAttendanceErrorMessage(e: unknown): string { + const err = e as { response?: { data?: { error?: string } } } + const code = err?.response?.data?.error + if (code && SERVER_ERROR_MAP[code]) return SERVER_ERROR_MAP[code] + return "저장에 실패했습니다." +} From fdf0c800ba43861433d46e5900b34e44cefedc52 Mon Sep 17 00:00:00 2001 From: Kim Minhyeok Date: Fri, 24 Apr 2026 15:47:09 +0900 Subject: [PATCH 11/14] =?UTF-8?q?perf:=20isInSubtree=EC=97=90=20community?= =?UTF-8?q?=20=EB=A7=B5=2030=EC=B4=88=20=EC=BA=90=EC=8B=9C=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 출석 편집 요청마다 전체 community 테이블을 다시 로드하던 것을 모듈 레벨 Map에 TTL 30초로 캐시하여 반복 DB 조회를 제거합니다. 트리 구조 변경은 드물어 30초 지연이 허용됩니다. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/model/attendance.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/server/src/model/attendance.ts b/server/src/model/attendance.ts index 1e640d29..5f74418c 100644 --- a/server/src/model/attendance.ts +++ b/server/src/model/attendance.ts @@ -1,6 +1,22 @@ import { jwtPayload } from "../util/type" +import { Community } from "../entity/community" import { communityDatabase, userDatabase } from "./dataSource" +const COMMUNITY_CACHE_TTL_MS = 30_000 +let cachedCommunityMap: Map | null = null +let cachedAt = 0 + +async function getCommunityMap(): Promise> { + const now = Date.now() + if (cachedCommunityMap && now - cachedAt < COMMUNITY_CACHE_TTL_MS) { + return cachedCommunityMap + } + const all = await communityDatabase.find({ relations: { children: true } }) + cachedCommunityMap = new Map(all.map((c) => [c.id, c])) + cachedAt = now + return cachedCommunityMap +} + async function isInSubtree( ancestorId: number | undefined, targetId: number | undefined, @@ -8,8 +24,7 @@ async function isInSubtree( if (!ancestorId || !targetId) return false if (ancestorId === targetId) return true - const all = await communityDatabase.find({ relations: { children: true } }) - const byId = new Map(all.map((c) => [c.id, c])) + const byId = await getCommunityMap() const visited = new Set() function walk(id: number): boolean { From f0be3dc4c42a311e2749eae8081ff9ab8063296d Mon Sep 17 00:00:00 2001 From: Kim Minhyeok Date: Fri, 24 Apr 2026 15:47:17 +0900 Subject: [PATCH 12/14] =?UTF-8?q?fix:=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20-=20SSG=20=EB=A7=A5=EB=9D=BD=20=EC=A3=BC=EC=84=9D,?= =?UTF-8?q?=20=EC=9E=90=EC=8B=9D=20use=20client=20=EC=9B=90=EB=B3=B5,=20on?= =?UTF-8?q?Save=20=EA=B0=80=EB=8F=85=EC=84=B1=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AttendanceTable의 "SSR-safe" 주석을 "SSG"로 교정 (output:"export" 환경) - AttendanceTable/AttendCell에 추가됐던 "use client"는 부모에서 상속되는 프로젝트 관례와 어긋나므로 제거 - worshipScheduleMap 루프에서 onSave prop 삼항식을 handleSave 변수로 추출해 가독성 개선 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/app/admin/soon/attendance/AttendCell.tsx | 2 -- .../app/admin/soon/attendance/AttendanceTable.tsx | 15 ++++++--------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/client/src/app/admin/soon/attendance/AttendCell.tsx b/client/src/app/admin/soon/attendance/AttendCell.tsx index fc02c7ba..21bd9cbe 100644 --- a/client/src/app/admin/soon/attendance/AttendCell.tsx +++ b/client/src/app/admin/soon/attendance/AttendCell.tsx @@ -1,5 +1,3 @@ -"use client" - import { useState } from "react" import { Box, diff --git a/client/src/app/admin/soon/attendance/AttendanceTable.tsx b/client/src/app/admin/soon/attendance/AttendanceTable.tsx index 827939ea..34e71edd 100644 --- a/client/src/app/admin/soon/attendance/AttendanceTable.tsx +++ b/client/src/app/admin/soon/attendance/AttendanceTable.tsx @@ -1,5 +1,3 @@ -"use client" - import { Box, Stack, @@ -45,7 +43,7 @@ export default function AttendanceTable({ onSaveCell, }: AttendanceTableProps) { const theme = useTheme() - // SSR-safe: 서버 렌더 시엔 false, 마운트 후 실제 window 크기로 재계산 + // SSG: 빌드 시엔 false, 마운트 후 실제 window 크기로 재계산 const isMobile = useMediaQuery(theme.breakpoints.down("sm")) return ( @@ -205,6 +203,10 @@ export default function AttendanceTable({ data.user.id === user.id && data.worshipSchedule.id === worshipSchedule.id, ) + const handleSave = onSaveCell + ? (status: AttendStatus, memo: string) => + onSaveCell(user.id, worshipSchedule.id, status, memo) + : undefined return ( - onSaveCell(user.id, worshipSchedule.id, status, memo) - : undefined - } + onSave={handleSave} /> ) From 3a485366ce9c817344d6562fc1e7ade68f098279 Mon Sep 17 00:00:00 2001 From: Kim Minhyeok Date: Fri, 24 Apr 2026 16:08:17 +0900 Subject: [PATCH 13/14] =?UTF-8?q?refactor:=20=EC=B6=9C=EC=84=9D=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EC=A0=80=EC=9E=A5/=EB=B3=B5=EA=B5=AC?= =?UTF-8?q?=EB=A5=BC=20=EC=84=9C=EB=B2=84=20bulk=20endpoint=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 클라에서 10개씩 병렬 HTTP 요청을 나눠 보내던 runBulkSave와 handleUndo를 신설한 POST /update-attendance-bulk 한 번의 요청으로 통합합니다. 서버는 최대 100건까지 받아 각 아이템별로 검증·권한 체크·저장을 수행하고, 부분 실패 시 HTTP 207 Multi-Status와 per-item 결과를 반환합니다. 클라는 결과의 status 기반으로 성공 userId를 계산하고 첫 실패 사유를 토스트에 표시합니다. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/app/admin/soon/attendance/EditTab.tsx | 91 +++++++++++-------- server/src/routes/admin/soonRouter.ts | 88 ++++++++++++++++++ 2 files changed, 139 insertions(+), 40 deletions(-) diff --git a/client/src/app/admin/soon/attendance/EditTab.tsx b/client/src/app/admin/soon/attendance/EditTab.tsx index daab3970..a1542e00 100644 --- a/client/src/app/admin/soon/attendance/EditTab.tsx +++ b/client/src/app/admin/soon/attendance/EditTab.tsx @@ -334,26 +334,33 @@ export default function EditTab() { }) setSaving(true) - const BULK_SAVE_BATCH_SIZE = 10 - const results: PromiseSettledResult[] = [] - for (let i = 0; i < ids.length; i += BULK_SAVE_BATCH_SIZE) { - const batchIds = ids.slice(i, i + BULK_SAVE_BATCH_SIZE) - const batchResults = await Promise.allSettled( - batchIds.map((userId) => - axios.post("/admin/soon/update-attendance", { - userId, - worshipScheduleId: selectedScheduleId, - isAttend: status, - memo, - }), - ), - ) - results.push(...batchResults) + let successfulIds: string[] = [] + let firstFailureMessage = "" + try { + const response = await axios.post<{ + results: Array<{ + index: number + userId: string + status: "ok" | "forbidden" | "invalid" | "error" + error?: string + }> + }>("/admin/soon/update-attendance-bulk", { + worshipScheduleId: selectedScheduleId, + items: ids.map((userId) => ({ userId, isAttend: status, memo })), + }) + successfulIds = response.data.results + .filter((r) => r.status === "ok") + .map((r) => r.userId) + const firstFail = response.data.results.find((r) => r.status !== "ok") + if (firstFail) { + firstFailureMessage = + firstFail.status === "forbidden" + ? "해당 유저의 출석을 편집할 권한이 없습니다." + : firstFail.error || "저장에 실패했습니다." + } + } catch (e) { + firstFailureMessage = toAttendanceErrorMessage(e) } - const successfulIds: string[] = [] - ids.forEach((id, i) => { - if (results[i].status === "fulfilled") successfulIds.push(id) - }) setAttendData((prev) => { const map = new Map(prev.map((d) => [d.user.id, d])) @@ -379,14 +386,10 @@ export default function EditTab() { setSaving(false) const failed = ids.length - successfulIds.length if (failed > 0) { - const firstFailure = results.find( - (r) => r.status === "rejected", - ) as PromiseRejectedResult | undefined - const reason = firstFailure - ? toAttendanceErrorMessage(firstFailure.reason) - : "" error( - reason ? `${failed}건 저장 실패 — ${reason}` : `${failed}건 저장 실패`, + firstFailureMessage + ? `${failed}건 저장 실패 — ${firstFailureMessage}` + : `${failed}건 저장 실패`, ) } @@ -426,20 +429,28 @@ export default function EditTab() { return } - const results = await Promise.allSettled( - restorable.map((userId) => { - const prev = action.previousStates.get(userId)! - return axios.post("/admin/soon/update-attendance", { - userId, - worshipScheduleId: action.scheduleId, - isAttend: prev.status, - memo: prev.memo, - }) - }), - ) - const successIds = restorable.filter( - (_, i) => results[i].status === "fulfilled", - ) + let successIds: string[] = [] + try { + const response = await axios.post<{ + results: Array<{ + index: number + userId: string + status: "ok" | "forbidden" | "invalid" | "error" + error?: string + }> + }>("/admin/soon/update-attendance-bulk", { + worshipScheduleId: action.scheduleId, + items: restorable.map((userId) => { + const prev = action.previousStates.get(userId)! + return { userId, isAttend: prev.status, memo: prev.memo } + }), + }) + successIds = response.data.results + .filter((r) => r.status === "ok") + .map((r) => r.userId) + } catch { + // 네트워크 에러 등: successIds 빈 배열 유지 + } // 로컬 상태 복원 setAttendData((prev) => { diff --git a/server/src/routes/admin/soonRouter.ts b/server/src/routes/admin/soonRouter.ts index 7c8c3b94..03177e38 100644 --- a/server/src/routes/admin/soonRouter.ts +++ b/server/src/routes/admin/soonRouter.ts @@ -250,4 +250,92 @@ router.post("/update-attendance", async (req, res) => { res.send({ result: "success" }) }) +const BULK_MAX_ITEMS = 100 + +type BulkResult = { + index: number + userId: string + status: "ok" | "forbidden" | "invalid" | "error" + error?: string +} + +router.post("/update-attendance-bulk", async (req, res) => { + const jwt = await checkJwt(req) + if (!jwt) { + res.status(401).send({ error: "Unauthorized" }) + return + } + + const { worshipScheduleId, items } = req.body + if (!worshipScheduleId) { + res.status(400).send({ error: "Missing worshipScheduleId" }) + return + } + if (!Array.isArray(items) || items.length === 0) { + res.status(400).send({ error: "Missing items" }) + return + } + if (items.length > BULK_MAX_ITEMS) { + res.status(413).send({ error: "Too many items" }) + return + } + + const results: BulkResult[] = await Promise.all( + items.map(async (item, index): Promise => { + const { userId, isAttend, memo } = item ?? {} + + if (!userId || !isAttend) { + return { index, userId, status: "invalid", error: "Missing required fields" } + } + if (!(Object.values(AttendStatus) as string[]).includes(isAttend)) { + return { index, userId, status: "invalid", error: "Invalid isAttend value" } + } + if (memo !== undefined && memo !== null) { + if (typeof memo !== "string") { + return { index, userId, status: "invalid", error: "Invalid memo type" } + } + if (memo.length > 500) { + return { index, userId, status: "invalid", error: "Memo too long" } + } + } + + const allowed = await canEditUserAttendance(jwt, userId) + if (!allowed) { + return { index, userId, status: "forbidden" } + } + + try { + const existing = await attendDataDatabase.findOne({ + where: { + user: { id: userId }, + worshipSchedule: { id: worshipScheduleId }, + }, + }) + if (existing) { + existing.isAttend = isAttend + if (typeof memo === "string") { + existing.memo = memo + } + await attendDataDatabase.save(existing) + } else { + await attendDataDatabase.save( + attendDataDatabase.create({ + user: { id: userId }, + worshipSchedule: { id: worshipScheduleId }, + isAttend, + memo: typeof memo === "string" ? memo : "", + }), + ) + } + return { index, userId, status: "ok" } + } catch { + return { index, userId, status: "error", error: "Save failed" } + } + }), + ) + + const hasFailure = results.some((r) => r.status !== "ok") + res.status(hasFailure ? 207 : 200).send({ results }) +}) + export default router From d4cbc8c9ee697e35803b2d03fd87439dd95031b0 Mon Sep 17 00:00:00 2001 From: Kim Minhyeok Date: Sun, 26 Apr 2026 12:22:30 +0900 Subject: [PATCH 14/14] =?UTF-8?q?refactor:=20bulk=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EA=B3=BC=20=EB=A7=A4=ED=95=91=20=ED=95=A8?= =?UTF-8?q?=EC=88=98=EB=A5=BC=20attendanceError=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EditTab의 runBulkSave/handleUndo 두 군데에 인라인으로 정의돼 있던 BulkAttendanceResponse 타입을 util/attendanceError.ts로 옮기고, 인라인 status 매핑(forbidden/invalid/error → 한국어)도 toBulkResultMessage 함수로 추출했습니다. 새 status 추가 시 매핑 테이블 한 곳만 수정하면 됩니다. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/app/admin/soon/attendance/EditTab.tsx | 53 ++++++++----------- client/src/util/attendanceError.ts | 27 ++++++++++ 2 files changed, 50 insertions(+), 30 deletions(-) diff --git a/client/src/app/admin/soon/attendance/EditTab.tsx b/client/src/app/admin/soon/attendance/EditTab.tsx index a1542e00..f1391fb2 100644 --- a/client/src/app/admin/soon/attendance/EditTab.tsx +++ b/client/src/app/admin/soon/attendance/EditTab.tsx @@ -35,7 +35,11 @@ import { Community } from "@server/entity/community" import { User } from "@server/entity/user" import { WorshipSchedule } from "@server/entity/worshipSchedule" import { worshipKr } from "@/util/worship" -import { toAttendanceErrorMessage } from "@/util/attendanceError" +import { + BulkAttendanceResponse, + toAttendanceErrorMessage, + toBulkResultMessage, +} from "@/util/attendanceError" import { useNotification } from "@/hooks/useNotification" type StatusFilter = "all" | "unrecorded" | "ATTEND" | "ABSENT" | "ETC" @@ -337,26 +341,19 @@ export default function EditTab() { let successfulIds: string[] = [] let firstFailureMessage = "" try { - const response = await axios.post<{ - results: Array<{ - index: number - userId: string - status: "ok" | "forbidden" | "invalid" | "error" - error?: string - }> - }>("/admin/soon/update-attendance-bulk", { - worshipScheduleId: selectedScheduleId, - items: ids.map((userId) => ({ userId, isAttend: status, memo })), - }) + const response = await axios.post( + "/admin/soon/update-attendance-bulk", + { + worshipScheduleId: selectedScheduleId, + items: ids.map((userId) => ({ userId, isAttend: status, memo })), + }, + ) successfulIds = response.data.results .filter((r) => r.status === "ok") .map((r) => r.userId) const firstFail = response.data.results.find((r) => r.status !== "ok") if (firstFail) { - firstFailureMessage = - firstFail.status === "forbidden" - ? "해당 유저의 출석을 편집할 권한이 없습니다." - : firstFail.error || "저장에 실패했습니다." + firstFailureMessage = toBulkResultMessage(firstFail) } } catch (e) { firstFailureMessage = toAttendanceErrorMessage(e) @@ -431,20 +428,16 @@ export default function EditTab() { let successIds: string[] = [] try { - const response = await axios.post<{ - results: Array<{ - index: number - userId: string - status: "ok" | "forbidden" | "invalid" | "error" - error?: string - }> - }>("/admin/soon/update-attendance-bulk", { - worshipScheduleId: action.scheduleId, - items: restorable.map((userId) => { - const prev = action.previousStates.get(userId)! - return { userId, isAttend: prev.status, memo: prev.memo } - }), - }) + const response = await axios.post( + "/admin/soon/update-attendance-bulk", + { + worshipScheduleId: action.scheduleId, + items: restorable.map((userId) => { + const prev = action.previousStates.get(userId)! + return { userId, isAttend: prev.status, memo: prev.memo } + }), + }, + ) successIds = response.data.results .filter((r) => r.status === "ok") .map((r) => r.userId) diff --git a/client/src/util/attendanceError.ts b/client/src/util/attendanceError.ts index b241f1d6..aa6ae230 100644 --- a/client/src/util/attendanceError.ts +++ b/client/src/util/attendanceError.ts @@ -1,3 +1,16 @@ +export type BulkAttendanceStatus = "ok" | "forbidden" | "invalid" | "error" + +export type BulkAttendanceResultItem = { + index: number + userId: string + status: BulkAttendanceStatus + error?: string +} + +export type BulkAttendanceResponse = { + results: BulkAttendanceResultItem[] +} + const SERVER_ERROR_MAP: Record = { Unauthorized: "로그인이 필요합니다.", Forbidden: "해당 유저의 출석을 편집할 권한이 없습니다.", @@ -7,9 +20,23 @@ const SERVER_ERROR_MAP: Record = { "Memo too long": "메모는 500자 이내로 작성해주세요.", } +const BULK_STATUS_MAP: Record = { + ok: "", + forbidden: "해당 유저의 출석을 편집할 권한이 없습니다.", + invalid: "잘못된 입력입니다.", + error: "저장에 실패했습니다.", +} + export function toAttendanceErrorMessage(e: unknown): string { const err = e as { response?: { data?: { error?: string } } } const code = err?.response?.data?.error if (code && SERVER_ERROR_MAP[code]) return SERVER_ERROR_MAP[code] return "저장에 실패했습니다." } + +export function toBulkResultMessage(item: BulkAttendanceResultItem): string { + if (item.error && SERVER_ERROR_MAP[item.error]) { + return SERVER_ERROR_MAP[item.error] + } + return BULK_STATUS_MAP[item.status] || "저장에 실패했습니다." +}