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..34e71edd 100644 --- a/client/src/app/admin/soon/attendance/AttendanceTable.tsx +++ b/client/src/app/admin/soon/attendance/AttendanceTable.tsx @@ -1,7 +1,16 @@ -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" +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 +24,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,8 +39,12 @@ export default function AttendanceTable({ worshipScheduleMapList, leaders, getAttendUserCount, + editable = false, + onSaveCell, }: AttendanceTableProps) { - const isMobile = global.innerWidth < 600 + const theme = useTheme() + // SSG: 빌드 시엔 false, 마운트 후 실제 window 크기로 재계산 + const isMobile = useMediaQuery(theme.breakpoints.down("sm")) return ( {/* 출석 테이블 제목 */} @@ -183,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 ( - + ) })} 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..f1391fb2 --- /dev/null +++ b/client/src/app/admin/soon/attendance/EditTab.tsx @@ -0,0 +1,1174 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" +import { + Box, + Stack, + Typography, + 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 CloseIcon from "@mui/icons-material/Close" + +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 { + BulkAttendanceResponse, + toAttendanceErrorMessage, + toBulkResultMessage, +} from "@/util/attendanceError" +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")) + // bulk 버튼 라벨 숨김 기준 — 600px 미만에선 아이콘만 + const isNarrow = useMediaQuery(theme.breakpoints.down("sm")) + + // 초기 로드 + 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) + let successfulIds: string[] = [] + let firstFailureMessage = "" + try { + 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 = toBulkResultMessage(firstFail) + } + } catch (e) { + firstFailureMessage = toAttendanceErrorMessage(e) + } + + 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( + firstFailureMessage + ? `${failed}건 저장 실패 — ${firstFailureMessage}` + : `${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 + } + + let successIds: string[] = [] + try { + 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) + } catch { + // 네트워크 에러 등: successIds 빈 배열 유지 + } + + // 로컬 상태 복원 + 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) { + 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") { + const full = memo ? `결석 · ${memo}` : "결석" + return ( + + ) + } + if (status === "ETC") { + const full = memo ? `기타 · ${memo}` : "기타" + 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 ( + + {/* 컨트롤 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 }, + { 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)} + /> + + + {/* ↑ sticky 래퍼 종료 */} + + {/* 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={{ + // 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( + undoAction.newStatus, + )}' 적용됨` + : "" + } + action={ + + } + /> + + {/* Sticky bulk action bar */} + {checkedIds.size > 0 && ( + + + + + ✓ {checkedIds.size}명 선택 + + {hiddenSelectedCount > 0 && ( + + (화면 밖 {hiddenSelectedCount}명 포함) + + )} + + + + + + + + + + + )} + + ) +} + +function statusLabel(status: AttendStatus) { + if (status === AttendStatus.ATTEND) return "출석" + if (status === AttendStatus.ABSENT) return "결석" + return "기타" +} + +/* --- 하위 컴포넌트 --- */ + +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} + + ) +} 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..8df78ab0 --- /dev/null +++ b/client/src/app/admin/soon/attendance/OverviewTab.tsx @@ -0,0 +1,252 @@ +"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" +import { toAttendanceErrorMessage } from "@/util/attendanceError" + +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) { + error(toAttendanceErrorMessage(e)) + } + } + + 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..eb7951b1 100644 --- a/client/src/app/leader/all-attendance/page.tsx +++ b/client/src/app/leader/all-attendance/page.tsx @@ -37,12 +37,15 @@ export default function AttendanceAdminPage() { const { error } = useNotification() useEffect(() => { - fetchCommunities() - if (!authUserData?.role.VillageLeader) { + // authUserData가 비동기로 로드되므로 준비될 때까지 판정 보류 + if (!authUserData) return + if (!authUserData.role.VillageLeader) { error("접근 권한이 없습니다.") push("/leader") + return } - }, []) + fetchCommunities() + }, [authUserData]) async function fetchCommunities() { const data = await get("/admin/community") diff --git a/client/src/util/attendanceError.ts b/client/src/util/attendanceError.ts new file mode 100644 index 00000000..aa6ae230 --- /dev/null +++ b/client/src/util/attendanceError.ts @@ -0,0 +1,42 @@ +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: "해당 유저의 출석을 편집할 권한이 없습니다.", + "Missing required fields": "필수 정보가 누락되었습니다.", + "Invalid isAttend value": "잘못된 출석 값입니다.", + "Invalid memo type": "메모 형식이 올바르지 않습니다.", + "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] || "저장에 실패했습니다." +} diff --git a/server/src/model/attendance.ts b/server/src/model/attendance.ts new file mode 100644 index 00000000..5f74418c --- /dev/null +++ b/server/src/model/attendance.ts @@ -0,0 +1,62 @@ +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, +): Promise { + if (!ancestorId || !targetId) return false + if (ancestorId === targetId) return true + + const byId = await getCommunityMap() + 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 4a47c651..03177e38 100644 --- a/server/src/routes/admin/soonRouter.ts +++ b/server/src/routes/admin/soonRouter.ts @@ -1,11 +1,12 @@ import express from "express" -import { hasPermission } from "../../util/util" -import { PermissionType } from "../../entity/types" +import { checkJwt, hasPermission } from "../../util/util" +import { AttendStatus, PermissionType } from "../../entity/types" import { attendDataDatabase, communityDatabase, userDatabase, } from "../../model/dataSource" +import { canEditUserAttendance } from "../../model/attendance" import { In, Not, IsNull } from "typeorm" import _ from "lodash" @@ -186,4 +187,155 @@ router.post("/user-attendance", async (req, res) => { res.status(200).send(attendDataList) }) +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 + } + + 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" }) + return + } + + 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) + res.send({ result: "success" }) + return + } + + await attendDataDatabase.save( + attendDataDatabase.create({ + user: { id: userId }, + worshipSchedule: { id: worshipScheduleId }, + isAttend, + memo: typeof memo === "string" ? memo : "", + }), + ) + 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