-
Notifications
You must be signed in to change notification settings - Fork 0
add: 마을/다락방 출석 편집 기능 및 출석 API 보안 강화 #73
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d52dc58
7a908bc
c702b2a
c6ff9f5
3fe792c
140a368
a0f6a20
c56468e
c657935
6085986
fdf0c80
f0be3dc
3a48536
d4cbc8c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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> | ||
| )} | ||
| </> | ||
| ) | ||
| } | ||
| 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
|
||
| 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<void> | ||
| } | ||
|
|
||
| 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 ( | ||
| <Box sx={{ p: 2 }}> | ||
| {/* 출석 테이블 제목 */} | ||
|
|
@@ -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} | ||
|
|
@@ -197,7 +221,11 @@ export default function AttendanceTable({ | |
| justifyContent: "center", | ||
| }} | ||
| > | ||
| <AttendCell attendData={attendData} /> | ||
| <AttendCell | ||
| attendData={attendData} | ||
| editable={editable} | ||
| onSave={handleSave} | ||
| /> | ||
| </Box> | ||
| ) | ||
| })} | ||
|
|
||
There was a problem hiding this comment.
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.