diff --git a/client/src/app/leader/newcomer/layout.tsx b/client/src/app/leader/newcomer/layout.tsx
index e679b1dd..647cb1f0 100644
--- a/client/src/app/leader/newcomer/layout.tsx
+++ b/client/src/app/leader/newcomer/layout.tsx
@@ -7,6 +7,7 @@ const menuItems = [
{ label: "새신자 등록/조회", path: "/leader/newcomer/management" },
{ label: "교육 현황", path: "/leader/newcomer/education" },
{ label: "섬김이 관리", path: "/leader/newcomer/managers" },
+ { label: "주차별 예상 참석", path: "/leader/newcomer/weekly-attendance" },
]
export default function NewcomerLayout({
diff --git a/client/src/app/leader/newcomer/management/NewcomerFilter.tsx b/client/src/app/leader/newcomer/management/NewcomerFilter.tsx
index 39560e60..995ae49a 100644
--- a/client/src/app/leader/newcomer/management/NewcomerFilter.tsx
+++ b/client/src/app/leader/newcomer/management/NewcomerFilter.tsx
@@ -9,6 +9,8 @@ interface NewcomerFilterProps {
setFilterMinYear: (value: string) => void
filterMaxYear: string
setFilterMaxYear: (value: string) => void
+ filterStatus: string
+ setFilterStatus: (value: string) => void
clearFilters: () => void
}
@@ -21,6 +23,8 @@ export default function NewcomerFilter({
setFilterMinYear,
filterMaxYear,
setFilterMaxYear,
+ filterStatus,
+ setFilterStatus,
clearFilters,
}: NewcomerFilterProps) {
return (
@@ -90,6 +94,26 @@ export default function NewcomerFilter({
+
+
+ 상태:
+
+ setFilterStatus(e.target.value)}
+ variant="outlined"
+ sx={{ flex: 1 }}
+ >
+
+
+
+
+
+
+
+
생년:
diff --git a/client/src/app/leader/newcomer/management/NewcomerForm.tsx b/client/src/app/leader/newcomer/management/NewcomerForm.tsx
index 547fabf6..e70b8bfc 100644
--- a/client/src/app/leader/newcomer/management/NewcomerForm.tsx
+++ b/client/src/app/leader/newcomer/management/NewcomerForm.tsx
@@ -11,6 +11,7 @@ interface Newcomer {
yearOfBirth: number | null
phone: string | null
gender: "man" | "woman" | "" | null
+ status?: "NORMAL" | "PROMOTED" | "PENDING" | "DELETED"
newcomerManager?: {
id: string
user: { id: string; name: string }
@@ -22,6 +23,8 @@ interface NewcomerFormProps {
onDataChange: (key: string, value: any) => void
onSave: () => void
onDelete: () => void
+ onPending: () => void
+ onPromote: () => void
onClear: () => void
managerList: Manager[]
}
@@ -31,9 +34,26 @@ export default function NewcomerForm({
onDataChange,
onSave,
onDelete,
+ onPending,
+ onPromote,
onClear,
managerList,
}: NewcomerFormProps) {
+ const getStatusLabel = (status?: string) => {
+ switch (status) {
+ case "NORMAL":
+ return "활동중"
+ case "PROMOTED":
+ return "등반"
+ case "PENDING":
+ return "보류"
+ case "DELETED":
+ return "삭제"
+ default:
+ return "활동중"
+ }
+ }
+
return (
- {selectedNewcomer.id && (
-
+ {selectedNewcomer.id && selectedNewcomer.status !== "DELETED" && (
+ <>
+
+
+
+ >
)}
+
+
+
+ 상태 :
+
+
+ {getStatusLabel(selectedNewcomer.status)}
+
+
)
}
diff --git a/client/src/app/leader/newcomer/management/NewcomerTable.tsx b/client/src/app/leader/newcomer/management/NewcomerTable.tsx
index f94fac61..7865cc0a 100644
--- a/client/src/app/leader/newcomer/management/NewcomerTable.tsx
+++ b/client/src/app/leader/newcomer/management/NewcomerTable.tsx
@@ -16,6 +16,10 @@ interface Newcomer {
phone: string | null
gender: "man" | "woman" | "" | null
status: string
+ createdAt: string
+ deletedAt?: string | null
+ pendingDate?: string | null
+ promotionDate?: string | null
}
interface NewcomerTableProps {
@@ -35,6 +39,21 @@ export default function NewcomerTable({
onSortClick,
onNewcomerSelect,
}: NewcomerTableProps) {
+ const getStatusLabel = (status?: string) => {
+ switch (status) {
+ case "NORMAL":
+ return "활동중"
+ case "PROMOTED":
+ return "등반"
+ case "PENDING":
+ return "보류"
+ case "DELETED":
+ return "삭제"
+ default:
+ return ""
+ }
+ }
+
return (
+
+ onSortClick("status")}
+ >
+ 상태
+
+
+
+ onSortClick("createdAt")}
+ >
+ 등록일
+
+
+ 보류일
+ 등반일
+ 삭제일
@@ -108,6 +148,27 @@ export default function NewcomerTable({
: newcomer.yearOfBirth}
{newcomer.phone || ""}
+ {getStatusLabel(newcomer.status)}
+
+ {newcomer.createdAt
+ ? new Date(newcomer.createdAt).toLocaleDateString("ko-KR")
+ : ""}
+
+
+ {newcomer.pendingDate
+ ? new Date(newcomer.pendingDate).toLocaleDateString("ko-KR")
+ : ""}
+
+
+ {newcomer.promotionDate
+ ? new Date(newcomer.promotionDate).toLocaleDateString("ko-KR")
+ : ""}
+
+
+ {newcomer.deletedAt
+ ? new Date(newcomer.deletedAt).toLocaleDateString("ko-KR")
+ : ""}
+
))}
diff --git a/client/src/app/leader/newcomer/management/page.tsx b/client/src/app/leader/newcomer/management/page.tsx
index 8ce3ad00..ff8dde28 100644
--- a/client/src/app/leader/newcomer/management/page.tsx
+++ b/client/src/app/leader/newcomer/management/page.tsx
@@ -1,6 +1,14 @@
"use client"
-import { Stack } from "@mui/material"
+import {
+ Stack,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Button,
+ TextField,
+} from "@mui/material"
import { useEffect, useState } from "react"
import { useSetAtom } from "jotai"
import axios from "@/config/axios"
@@ -16,7 +24,7 @@ interface Newcomer {
yearOfBirth: number | null
phone: string | null
gender: "man" | "woman" | "" | null
- status: string
+ status: "NORMAL" | "PROMOTED" | "PENDING" | "DELETED"
guider: { id: string; name: string } | null
newcomerManager: {
id: string
@@ -24,6 +32,9 @@ interface Newcomer {
} | null
assignment: { id: number; name: string } | null
createdAt: string
+ deletedAt?: string | null
+ pendingDate?: string | null
+ promotionDate?: string | null
}
interface Manager {
@@ -42,6 +53,9 @@ const emptyNewcomer: Newcomer = {
newcomerManager: null,
assignment: null,
createdAt: "",
+ deletedAt: null,
+ pendingDate: null,
+ promotionDate: null,
}
export default function NewcomerManagement() {
@@ -58,8 +72,16 @@ export default function NewcomerManagement() {
const [filterGender, setFilterGender] = useState<"" | "man" | "woman">("")
const [filterMinYear, setFilterMinYear] = useState("")
const [filterMaxYear, setFilterMaxYear] = useState("")
+ const [filterStatus, setFilterStatus] = useState("")
const [managerList, setManagerList] = useState([])
+ // 날짜 선택 다이얼로그 상태
+ const [dateDialogOpen, setDateDialogOpen] = useState(false)
+ const [dateDialogType, setDateDialogType] = useState<
+ "pending" | "promotion" | "delete" | ""
+ >("")
+ const [selectedDate, setSelectedDate] = useState("")
+
useEffect(() => {
isLeaderIfNotExit("/leader/newcomer/management")
fetchData()
@@ -119,15 +141,65 @@ export default function NewcomerManagement() {
}
async function deleteNewcomer() {
- if (selectedNewcomer.id && confirm("정말로 삭제하시겠습니까?")) {
- try {
+ if (selectedNewcomer.id) {
+ const today = new Date().toISOString().split("T")[0]
+ setSelectedDate(today)
+ setDateDialogType("delete")
+ setDateDialogOpen(true)
+ }
+ }
+
+ async function pendingNewcomer() {
+ if (selectedNewcomer.id) {
+ const today = new Date().toISOString().split("T")[0]
+ setSelectedDate(today)
+ setDateDialogType("pending")
+ setDateDialogOpen(true)
+ }
+ }
+
+ async function promoteNewcomer() {
+ if (selectedNewcomer.id) {
+ const today = new Date().toISOString().split("T")[0]
+ setSelectedDate(today)
+ setDateDialogType("promotion")
+ setDateDialogOpen(true)
+ }
+ }
+
+ async function handleDateConfirm() {
+ if (!selectedDate) {
+ setNotificationMessage("날짜를 선택해주세요.")
+ return
+ }
+
+ try {
+ if (dateDialogType === "delete") {
await axios.delete(`/newcomer/${selectedNewcomer.id}`)
setNotificationMessage("새신자가 삭제되었습니다.")
clearSelectedNewcomer()
- await fetchData()
- } catch (error) {
- setNotificationMessage("삭제 중 오류가 발생했습니다.")
+ } else if (dateDialogType === "pending") {
+ await axios.put(`/newcomer/${selectedNewcomer.id}`, {
+ ...selectedNewcomer,
+ status: "PENDING",
+ pendingDate: selectedDate,
+ })
+ setNotificationMessage("새신자가 보류 처리되었습니다.")
+ } else if (dateDialogType === "promotion") {
+ await axios.put(`/newcomer/${selectedNewcomer.id}`, {
+ ...selectedNewcomer,
+ status: "PROMOTED",
+ promotionDate: selectedDate,
+ })
+ setNotificationMessage("새신자가 등반 처리되었습니다.")
}
+
+ setDateDialogOpen(false)
+ setDateDialogType("")
+ setSelectedDate("")
+ await fetchData()
+ } catch (error) {
+ setNotificationMessage("처리 중 오류가 발생했습니다.")
}
}
@@ -136,6 +208,7 @@ export default function NewcomerManagement() {
setFilterGender("")
setFilterMinYear("")
setFilterMaxYear("")
+ setFilterStatus("")
}
function orderingNewcomerList() {
@@ -154,6 +227,10 @@ export default function NewcomerManagement() {
return false
}
+ if (filterStatus && newcomer.status !== filterStatus) {
+ return false
+ }
+
if (
filterMinYear &&
newcomer.yearOfBirth &&
@@ -212,7 +289,7 @@ export default function NewcomerManagement() {
onSortClick={handleSortClick}
onNewcomerSelect={setSelectedNewcomer}
/>
-
+
+
+
)
}
diff --git a/client/src/app/leader/newcomer/weekly-attendance/page.tsx b/client/src/app/leader/newcomer/weekly-attendance/page.tsx
new file mode 100644
index 00000000..81174067
--- /dev/null
+++ b/client/src/app/leader/newcomer/weekly-attendance/page.tsx
@@ -0,0 +1,219 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import {
+ Box,
+ Paper,
+ Typography,
+ Stack,
+ Grid,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ CircularProgress,
+ Chip,
+} from "@mui/material"
+import axios from "@/config/axios"
+
+enum EducationLecture {
+ OT = "OT",
+ L1 = "L1",
+ L2 = "L2",
+ L3 = "L3",
+ L4 = "L4",
+ L5 = "L5",
+}
+
+const LECTURE_ORDER = [
+ EducationLecture.L1,
+ EducationLecture.L2,
+ EducationLecture.L3,
+ EducationLecture.L4,
+ EducationLecture.L5,
+]
+
+const LECTURE_LABELS: Record = {
+ [EducationLecture.OT]: "OT",
+ [EducationLecture.L1]: "1강",
+ [EducationLecture.L2]: "2강",
+ [EducationLecture.L3]: "3강",
+ [EducationLecture.L4]: "4강",
+ [EducationLecture.L5]: "5강",
+ PROMOTION: "등반 예정",
+}
+
+interface WorshipSchedule {
+ id: string
+ date: string
+ lectureType?: string
+}
+
+interface NewcomerEducation {
+ id: string
+ lectureType: EducationLecture
+ worshipSchedule?: WorshipSchedule
+}
+
+interface Newcomer {
+ id: string
+ name: string
+ status: string
+ educationRecords: NewcomerEducation[]
+ phone?: string
+}
+
+export default function WeeklyAttendancePage() {
+ const [loading, setLoading] = useState(true)
+ const [newcomers, setNewcomers] = useState([])
+ const [stats, setStats] = useState([])
+
+ useEffect(() => {
+ fetchData()
+ }, [])
+
+ const fetchData = async () => {
+ try {
+ setLoading(true)
+ const res = await axios.get("/newcomer?status=NORMAL")
+ const data: Newcomer[] = res.data
+ setNewcomers(data)
+ calculateStats(data)
+ } catch (error) {
+ console.error("Failed to fetch newcomers", error)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const calculateStats = (data: Newcomer[]) => {
+ // Group newcomers by "Next Lecture"
+ const groups: Record = {
+ [EducationLecture.L1]: [],
+ [EducationLecture.L2]: [],
+ [EducationLecture.L3]: [],
+ [EducationLecture.L4]: [],
+ [EducationLecture.L5]: [],
+ PROMOTION: [],
+ }
+
+ data.forEach((nc) => {
+ const completed = new Set(nc.educationRecords.map((r) => r.lectureType))
+ let nextStep = "PROMOTION"
+
+ for (const lecture of LECTURE_ORDER) {
+ if (!completed.has(lecture)) {
+ nextStep = lecture
+ break
+ }
+ }
+ if (groups[nextStep]) {
+ groups[nextStep].push(nc)
+ }
+ })
+
+ // Build Stats Array for Display
+ const displayOrder = [
+ EducationLecture.L1,
+ EducationLecture.L2,
+ EducationLecture.L3,
+ EducationLecture.L4,
+ EducationLecture.L5,
+ "PROMOTION",
+ ]
+
+ const calculatedStats = displayOrder.map((step) => {
+ const waiting = groups[step] || []
+ const count = waiting.length
+
+ return {
+ step,
+ label: LECTURE_LABELS[step],
+ count,
+ people: waiting,
+ }
+ })
+
+ setStats(calculatedStats)
+ }
+
+ if (loading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+
+ 주차별 예상 참석자 현황
+
+
+
+ {stats.map((stat) => (
+
+
+
+
+ {stat.label}
+
+
+
+
+
+
+
+
+ 이름
+ 연락처
+
+
+
+ {stat.people.length > 0 ? (
+ stat.people.map((person: any) => (
+
+ {person.name}
+
+ {person.phone
+ ? person.phone.replace(
+ /(\d{3})(\d{4})(\d{4})/,
+ "$1-$2-$3",
+ )
+ : "-"}
+
+
+ ))
+ ) : (
+
+
+
+ 대상자가 없습니다.
+
+
+
+ )}
+
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/server/src/routes/newcomer/newcomerRouter.ts b/server/src/routes/newcomer/newcomerRouter.ts
index 00ac19fb..8365cb96 100644
--- a/server/src/routes/newcomer/newcomerRouter.ts
+++ b/server/src/routes/newcomer/newcomerRouter.ts
@@ -113,6 +113,8 @@ router.put("/:id", async (req, res) => {
assignmentId,
newcomerManagerId,
status,
+ pendingDate,
+ promotionDate,
} = req.body
try {
@@ -129,6 +131,8 @@ router.put("/:id", async (req, res) => {
if (phone !== undefined)
newcomer.phone = phone?.replace(/[^\d]/g, "") || null
if (status) newcomer.status = status
+ if (pendingDate !== undefined) newcomer.pendingDate = pendingDate
+ if (promotionDate !== undefined) newcomer.promotionDate = promotionDate
// 인도자 업데이트
if (guiderId !== undefined) {
@@ -172,7 +176,7 @@ router.put("/:id", async (req, res) => {
}
})
-// 1-2. 새신자 삭제
+// 1-2. 새신자 삭제 (소프트 딜리트)
router.delete("/:id", async (req, res) => {
const user = await checkJwt(req)
if (!user) {
@@ -183,7 +187,7 @@ router.delete("/:id", async (req, res) => {
const { id } = req.params
try {
- const result = await newcomerDatabase.delete(id)
+ const result = await newcomerDatabase.softDelete(id)
if (result.affected === 0) {
res.status(404).send({ error: "새신자를 찾을 수 없습니다." })
return