Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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"
Comment on lines +1 to +11
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component uses useState and renders a Popover, but the file is missing the "use client" directive. In Next.js App Router this will fail to compile because hooks can’t run in Server Components. Add "use client" at the top of this file.

Copilot uses AI. Check for mistakes.
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"
Comment on lines +1 to +9
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This component now uses React hooks (useTheme/useMediaQuery) but the file is missing the "use client" directive, which will cause a Next.js App Router build error (hooks in a Server Component / Client Component import boundary violation). Add "use client" at the top of this file.

Copilot uses AI. Check for mistakes.
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