Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
fd90d8b
update: 서버측 보안 강화
iubns Apr 15, 2026
5e560d4
update: 생일 및 등록일 수정 가능하도록
iubns Apr 22, 2026
d52dc58
add: 마을/다락방 출석 편집 기능 (입력 탭)
min1336 Apr 23, 2026
7a908bc
update: 출석 편집 페이지 모바일 최적화
min1336 Apr 23, 2026
c702b2a
refactor: EditTab 내 순수 함수 위치 정리
min1336 Apr 24, 2026
c6ff9f5
update: 권한 부족 응답 메시지 단축 (정보 노출 방지)
min1336 Apr 24, 2026
3fe792c
update: /update-attendance 입력 검증 강화
min1336 Apr 24, 2026
140a368
refactor: editable 계산에서 도달 불가능한 Leader 조건 제거
min1336 Apr 24, 2026
a0f6a20
remove: /leader/all-attendance 에서 출석 편집 기능 제거
min1336 Apr 24, 2026
c56468e
refactor: 출석 편집 권한 로직을 model로 분리 및 Leader 범위 명확화
min1336 Apr 24, 2026
c657935
fix: 클라이언트 컴포넌트 use client 지시어 및 leader 페이지 권한 체크 순서 교정
min1336 Apr 24, 2026
6085986
update: 출석 편집 UX/성능 개선 (에러 번역, 배치 저장, 미사용 import 제거)
min1336 Apr 24, 2026
fdf0c80
perf: isInSubtree에 community 맵 30초 캐시 추가
min1336 Apr 24, 2026
f0be3dc
fix: 리뷰 반영 - SSG 맥락 주석, 자식 use client 원복, onSave 가독성 개선
min1336 Apr 24, 2026
3a48536
refactor: 출석 일괄 저장/복구를 서버 bulk endpoint로 전환
min1336 Apr 24, 2026
d4cbc8c
refactor: bulk 응답 타입과 매핑 함수를 attendanceError 유틸로 통합
min1336 Apr 26, 2026
86e1b9d
Merge pull request #73 from nuon-dev/kimminhyeok
iubns Apr 27, 2026
21261ed
Merge pull request #72 from nuon-dev/main
iubns Apr 27, 2026
05e8550
fix: type error
iubns Apr 27, 2026
230cc0a
fix: 출석 입력을 4단(담당→마을→다락방→순원) drill-down으로 전환
min1336 Apr 28, 2026
b38fc3f
Merge pull request #74 from nuon-dev/kimminhyeok
iubns Apr 28, 2026
37695c4
Merge pull request #75 from nuon-dev/main
min1336 Apr 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 138 additions & 52 deletions client/src/app/admin/soon/attendance/AttendCell.tsx
Original file line number Diff line number Diff line change
@@ -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> | void
}

export default function AttendCell({ attendData }: AttendCellProps) {
if (!attendData) {
return (
<Box
width="100%"
height="100%"
textAlign="center"
alignContent="center"
justifyContent="center"
>
-
</Box>
)
export default function AttendCell({
attendData,
editable = false,
onSave,
}: AttendCellProps) {
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
const [status, setStatus] = useState<AttendStatus>(AttendStatus.ATTEND)
const [memo, setMemo] = useState("")
const [saving, setSaving] = useState(false)

function handleOpen(e: React.MouseEvent<HTMLElement>) {
if (!editable) return
setStatus(attendData?.isAttend ?? AttendStatus.ATTEND)
setMemo(attendData?.memo ?? "")
setAnchorEl(e.currentTarget)
}

if (attendData.isAttend === AttendStatus.ATTEND) {
return (
<Box
width="100%"
height="100%"
textAlign="center"
alignContent="center"
justifyContent="center"
style={{ backgroundColor: "rgb(184, 248,93)" }}
>
출석
</Box>
)
async function handleSave() {
if (!onSave) return
setSaving(true)
try {
await onSave(status, memo)
setAnchorEl(null)
} finally {
setSaving(false)
}
}

if (attendData.isAttend === AttendStatus.ABSENT) {
return (
<Box
width="100%"
height="100%"
textAlign="center"
alignContent="center"
justifyContent="center"
style={{ backgroundColor: "rgb(240, 148, 128)" }}
>
{attendData.memo}
</Box>
)
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 (
<>
<Box
width="100%"
height="100%"
textAlign="center"
alignContent="center"
justifyContent="center"
style={{ backgroundColor: "rgb(253,241,113)" }}
onClick={handleOpen}
sx={{
width: "100%",
height: "100%",
textAlign: "center",
alignContent: "center",
justifyContent: "center",
cursor: editable ? "pointer" : "default",
fontSize: "0.85rem",
bgcolor: bg,
transition: "opacity 0.15s",
"&:hover": editable ? { opacity: 0.72 } : {},
}}
>
{attendData.memo}
{label}
</Box>
)
}
}
{editable && (
<Popover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={() => {
if (!saving) setAnchorEl(null)
}}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
transformOrigin={{ vertical: "top", horizontal: "center" }}
>
<Stack p={2} spacing={1.5} minWidth={220}>
<Typography variant="caption" color="text.secondary">
출석 수정
</Typography>
<Select
size="small"
value={status}
onChange={(e) => setStatus(e.target.value as AttendStatus)}
>
<MenuItem value={AttendStatus.ATTEND}>
<Stack direction="row" alignItems="center" spacing={1}>
<CheckCircleIcon sx={{ color: "#4caf50", fontSize: 16 }} />
<span>출석</span>
</Stack>
</MenuItem>
<MenuItem value={AttendStatus.ABSENT}>
<Stack direction="row" alignItems="center" spacing={1}>
<CancelIcon sx={{ color: "#f44336", fontSize: 16 }} />
<span>결석</span>
</Stack>
</MenuItem>
<MenuItem value={AttendStatus.ETC}>
<Stack direction="row" alignItems="center" spacing={1}>
<HelpIcon sx={{ color: "#ff9800", fontSize: 16 }} />
<span>기타</span>
</Stack>
</MenuItem>
</Select>
{status !== AttendStatus.ATTEND && (
<TextField
size="small"
placeholder="사유"
value={memo}
onChange={(e) => setMemo(e.target.value)}
autoFocus
/>
)}
<Stack direction="row" spacing={1} justifyContent="flex-end">
<Button
size="small"
onClick={() => setAnchorEl(null)}
disabled={saving}
>
취소
</Button>
<Button
size="small"
variant="contained"
onClick={handleSave}
disabled={saving}
>
{saving ? "저장 중…" : "저장"}
</Button>
</Stack>
</Stack>
</Popover>
)}
</>
)
}
34 changes: 31 additions & 3 deletions client/src/app/admin/soon/attendance/AttendanceTable.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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<void>
}

export default function AttendanceTable({
Expand All @@ -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 (
<Box sx={{ p: 2 }}>
{/* 출석 테이블 제목 */}
Expand Down Expand Up @@ -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 (
<Box
key={worshipSchedule.id}
Expand All @@ -197,7 +221,11 @@ export default function AttendanceTable({
justifyContent: "center",
}}
>
<AttendCell attendData={attendData} />
<AttendCell
attendData={attendData}
editable={editable}
onSave={handleSave}
/>
</Box>
)
})}
Expand Down
Loading
Loading