From d0ea8b66e1bd28c1016e3fb58cb2cc809cb74d65 Mon Sep 17 00:00:00 2001 From: iubns Date: Mon, 27 Jul 2026 14:21:52 +0900 Subject: [PATCH] =?UTF-8?q?update:=20=EB=B0=A9=EB=B0=B0=EC=A0=95,=20?= =?UTF-8?q?=EC=A1=B0=EB=B0=B0=EC=A0=95=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../retreat/admin/group-formation/page.tsx | 588 ++++++++++++---- .../retreat/admin/room-assignment/page.tsx | 627 +++++++++++++----- .../src/routes/retreat/admin/retreatGroup.ts | 1 + server/src/routes/retreat/admin/room.ts | 1 + 4 files changed, 938 insertions(+), 279 deletions(-) diff --git a/client/src/app/retreat/admin/group-formation/page.tsx b/client/src/app/retreat/admin/group-formation/page.tsx index a4b4fef7..b84a8062 100644 --- a/client/src/app/retreat/admin/group-formation/page.tsx +++ b/client/src/app/retreat/admin/group-formation/page.tsx @@ -1,30 +1,90 @@ "use client" -import { Box, Stack } from "@mui/material" +import { Box, Button, IconButton, Stack, TextField } from "@mui/material" +import AddIcon from "@mui/icons-material/Add" +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline" +import RestartAltIcon from "@mui/icons-material/RestartAlt" import { useEffect, useState } from "react" -import { get, post } from "@/config/api" import { useRouter } from "next/navigation" -import { useSetAtom } from "jotai" -import { InOutInfo } from "@server/entity/retreat/inOutInfo" -import { RetreatAttend } from "@server/entity/retreat/retreatAttend" +import { get, post } from "@/config/api" import Header from "@/components/retreat/admin/Header" import { useNotification } from "@/hooks/useNotification" +import { InOutInfo } from "@server/entity/retreat/inOutInfo" +import { RetreatAttend } from "@server/entity/retreat/retreatAttend" + +type DraftGroup = { + id: string + groupNumber: number + members: Array +} + +type DraftState = { + groups: Array + unassignedUsers: Array +} function GroupFormation() { const { push } = useRouter() - const [unassignedUserList, setUnassignedUserList] = useState( - [] as Array, - ) - const [groupList, setGroupList] = useState([] as Array>) + const { error, success } = useNotification() + const [draftState, setDraftState] = useState({ + groups: [], + unassignedUsers: [], + }) const [selectedUser, setSelectedUser] = useState() const [mousePoint, setMousePoint] = useState([0, 0]) const [shiftPosition, setShiftPosition] = useState({ x: 0, y: 0 }) - const [maxGroupNumber, setMaxGroupNumber] = useState(0) const [isShowUserInfo, setIsShowUserInfo] = useState(false) const [showUserInfo, setShowUserInfo] = useState({} as RetreatAttend) - const [userAttendInfo, setUserAttendInfo] = useState([] as Array) - const { error } = useNotification() + const [isSaving, setIsSaving] = useState(false) + const [isDirty, setIsDirty] = useState(false) + const [groupNumberDrafts, setGroupNumberDrafts] = useState< + Record + >({}) + + function sortByBirthYear(userList: Array) { + return [...userList].sort((a, b) => a.user.yearOfBirth - b.user.yearOfBirth) + } + + function sortGroups(groups: Array) { + return [...groups].sort( + (left, right) => left.groupNumber - right.groupNumber, + ) + } + + function normalizeResponse(response: Array) { + const unassignedUsers = sortByBirthYear( + response.filter((user) => user.groupNumber === 0), + ) + + const groupMap = new Map>() + response + .filter((user) => user.groupNumber !== 0) + .forEach((user) => { + const currentGroup = groupMap.get(user.groupNumber) || [] + groupMap.set(user.groupNumber, [...currentGroup, user]) + }) + + const groups = [...groupMap.entries()].map(([groupNumber, members]) => ({ + id: `group-${groupNumber}`, + groupNumber, + members: sortByBirthYear(members), + })) + + return { + groups: sortGroups(groups), + unassignedUsers, + } + } + + function syncGroupNumberDrafts(groups: Array) { + setGroupNumberDrafts( + groups.reduce>((accumulator, group) => { + accumulator[group.id] = String(group.groupNumber) + return accumulator + }, {}), + ) + } function onMouseMove(event: MouseEvent) { setMousePoint([event.pageX, event.pageY]) @@ -42,82 +102,311 @@ function GroupFormation() { function fetchData() { get("/retreat/admin/get-retreat-group-formation") .then((response: Array) => { - const unassignedUserList = response - .filter((user) => user.groupNumber === 0) - .sort((a, b) => a.user.yearOfBirth - b.user.yearOfBirth) - setUnassignedUserList(unassignedUserList) - - const group = [] as Array> - const assignedUserList = response.filter( - (retreatAttend) => retreatAttend.groupNumber !== 0, - ) - assignedUserList.map((retreatAttend) => { - const groupNumber = retreatAttend.groupNumber - 1 - if (!group[groupNumber]) { - group[groupNumber] = [retreatAttend] - } else { - group[groupNumber].push(retreatAttend) - } - setGroupList(group) - }) - const maxNumber = Math.max( - ...response.map((retreatAttend) => retreatAttend.groupNumber), - ) - setMaxGroupNumber(maxNumber) + const nextState = normalizeResponse(response) + setDraftState(nextState) + syncGroupNumberDrafts(nextState.groups) + setIsDirty(false) + setSelectedUser(undefined) }) .catch(() => { push("/retreat/admin") error("권한이 없습니다.") - return }) } - function unassignedUserRow(retreatAttend: RetreatAttend) { - return ( - setSelectedUser(retreatAttend)} - onMouseUp={() => { - setGroup(0) - setSelectedUser(undefined) - }} - onMouseEnter={() => { - setModal(retreatAttend) - }} - onMouseLeave={() => { - setIsShowUserInfo(false) - }} - sx={{ - justifyContent: "space-between", - backgroundColor: - retreatAttend.user.gender === "man" ? "lightblue" : "pink", - }} - px="4px" - > - - {retreatAttend.user.name}({retreatAttend.user.yearOfBirth}) - {retreatAttend.user.etc || - (retreatAttend.inOutInfos && retreatAttend.inOutInfos.length) > 0 - ? "*" - : ""} - - + async function persistDraftState(nextState: DraftState) { + const usersById = new Map() + nextState.unassignedUsers.forEach((user) => { + usersById.set(user.id, user) + }) + nextState.groups.forEach((group) => { + group.members.forEach((user) => { + usersById.set(user.id, user) + }) + }) + + setIsSaving(true) + + try { + await Promise.all( + [...usersById.values()].map((selectedUser) => + post("/retreat/admin/set-retreat-group", { + selectedUser, + }), + ), + ) + setIsDirty(false) + } catch { + error("조 저장에 실패했습니다.") + } finally { + setIsSaving(false) + } + } + + function setDraftStateAndMarkDirty( + updater: (prev: DraftState) => DraftState, + autoSave = false, + ) { + setDraftState((prev) => { + const next = updater(prev) + if (next !== prev) { + setIsDirty(true) + if (autoSave) { + void persistDraftState(next) + } + } + return next + }) + } + + function getCurrentGroupNumber(state: DraftState, userId: string) { + if (state.unassignedUsers.some((user) => user.id === userId)) { + return 0 + } + + const currentGroup = state.groups.find((group) => + group.members.some((user) => user.id === userId), + ) + + return currentGroup?.groupNumber || 0 + } + + function moveUserToGroup( + state: DraftState, + userId: string, + nextGroupNumber: number, + ) { + const currentGroupNumber = getCurrentGroupNumber(state, userId) + if (currentGroupNumber === nextGroupNumber) { + return state + } + + const nextState: DraftState = { + groups: state.groups.map((group) => ({ + ...group, + members: [...group.members], + })), + unassignedUsers: [...state.unassignedUsers], + } + + let movingUser: RetreatAttend | undefined + + nextState.unassignedUsers = nextState.unassignedUsers.filter((user) => { + if (user.id === userId) { + movingUser = user + return false + } + + return true + }) + + if (!movingUser) { + for (const group of nextState.groups) { + const userIndex = group.members.findIndex((user) => user.id === userId) + if (userIndex !== -1) { + movingUser = group.members.splice(userIndex, 1)[0] + break + } + } + } + + if (!movingUser) { + return state + } + + const movedUser = { ...movingUser, groupNumber: nextGroupNumber } + + if (nextGroupNumber === 0) { + nextState.unassignedUsers = sortByBirthYear([ + ...nextState.unassignedUsers, + movedUser, + ]) + } else { + const targetGroup = nextState.groups.find( + (group) => group.groupNumber === nextGroupNumber, + ) + + if (targetGroup) { + targetGroup.members = sortByBirthYear([ + ...targetGroup.members, + movedUser, + ]) + } else { + nextState.groups.push({ + id: `group-${nextGroupNumber}-${movingUser.id}`, + groupNumber: nextGroupNumber, + members: [movedUser], + }) + } + } + + nextState.groups = sortGroups( + nextState.groups.map((group) => ({ + ...group, + members: sortByBirthYear(group.members), + })), + ) + nextState.unassignedUsers = sortByBirthYear(nextState.unassignedUsers) + + return nextState + } + + function applyDrop(nextGroupNumber: number) { + if (!selectedUser) { + return + } + + setDraftStateAndMarkDirty( + (prevState) => + moveUserToGroup(prevState, selectedUser.id, nextGroupNumber), + true, ) + setSelectedUser(undefined) + } + + function addGroup() { + setDraftStateAndMarkDirty((prevState) => { + const usedNumbers = new Set( + prevState.groups.map((group) => group.groupNumber), + ) + let nextGroupNumber = 1 + + while (usedNumbers.has(nextGroupNumber)) { + nextGroupNumber += 1 + } + + return { + ...prevState, + groups: sortGroups([ + ...prevState.groups, + { + id: `group-${nextGroupNumber}-${Date.now()}`, + groupNumber: nextGroupNumber, + members: [], + }, + ]), + } + }) + setGroupNumberDrafts((prevDrafts) => ({ + ...prevDrafts, + })) + } + + function removeGroup(groupId: string) { + setDraftStateAndMarkDirty((prevState) => { + const targetGroup = prevState.groups.find((group) => group.id === groupId) + if (!targetGroup) { + return prevState + } + + return { + groups: prevState.groups.filter((group) => group.id !== groupId), + unassignedUsers: sortByBirthYear([ + ...prevState.unassignedUsers, + ...targetGroup.members.map((user) => ({ + ...user, + groupNumber: 0, + })), + ]), + } + }, true) + setGroupNumberDrafts((prevDrafts) => { + const nextDrafts = { ...prevDrafts } + delete nextDrafts[groupId] + return nextDrafts + }) + } + + function renameGroup(groupId: string, nextGroupNumber: number) { + if (!Number.isFinite(nextGroupNumber) || nextGroupNumber <= 0) { + return + } + + setDraftStateAndMarkDirty((prevState) => { + const sourceGroup = prevState.groups.find((group) => group.id === groupId) + if (!sourceGroup || sourceGroup.groupNumber === nextGroupNumber) { + return prevState + } + + const targetGroup = prevState.groups.find( + (group) => + group.groupNumber === nextGroupNumber && group.id !== groupId, + ) + + if (targetGroup) { + const mergedMembers = sortByBirthYear([ + ...targetGroup.members, + ...sourceGroup.members.map((user) => ({ + ...user, + groupNumber: nextGroupNumber, + })), + ]) + + return { + ...prevState, + groups: sortGroups( + prevState.groups + .filter((group) => group.id !== groupId) + .map((group) => + group.id === targetGroup.id + ? { + ...group, + groupNumber: nextGroupNumber, + members: mergedMembers, + } + : group, + ), + ), + } + } + + return { + ...prevState, + groups: sortGroups( + prevState.groups.map((group) => + group.id === groupId + ? { + ...group, + groupNumber: nextGroupNumber, + members: sortByBirthYear( + group.members.map((user) => ({ + ...user, + groupNumber: nextGroupNumber, + })), + ), + } + : group, + ), + ), + } + }, true) + + setGroupNumberDrafts((prevDrafts) => { + const nextDrafts = { ...prevDrafts } + delete nextDrafts[groupId] + nextDrafts[groupId] = String(nextGroupNumber) + return nextDrafts + }) + } + + function setModal(retreatAttend: RetreatAttend) { + setIsShowUserInfo(true) + setShowUserInfo(retreatAttend) + setUserAttendInfo(retreatAttend.inOutInfos) } - function userGroupRow(retreatAttend: RetreatAttend) { + function renderUserRow(retreatAttend: RetreatAttend) { return ( { + key={retreatAttend.id} + onMouseDown={(event) => { setSelectedUser(retreatAttend) - const target = e.target as HTMLElement - const shiftX = e.clientX - target.getBoundingClientRect().left - const shiftY = e.clientY - target.getBoundingClientRect().top + const target = event.currentTarget as HTMLElement + const shiftX = event.clientX - target.getBoundingClientRect().left + const shiftY = event.clientY - target.getBoundingClientRect().top setShiftPosition({ x: shiftX, y: shiftY }) }} - width="160px" onMouseEnter={() => { setModal(retreatAttend) }} @@ -128,59 +417,94 @@ function GroupFormation() { justifyContent: "space-between", backgroundColor: retreatAttend.user.gender === "man" ? "lightblue" : "pink", + borderRadius: "6px", + cursor: "grab", + userSelect: "none", }} + px="4px" + py="2px" > {retreatAttend.user.name}({retreatAttend.user.yearOfBirth}) - {retreatAttend.etc || - (retreatAttend.inOutInfos && retreatAttend.inOutInfos.length) > 0 - ? "*" - : ""} + {retreatAttend.user.etc || retreatAttend.isHalf ? " (부참)" : ""} ) } - function setModal(retreatAttend: RetreatAttend) { - setIsShowUserInfo(true) - setShowUserInfo(retreatAttend) - setUserAttendInfo(retreatAttend.inOutInfos) - } - - function Group(groupNumber: number, userList: Array) { + function renderGroup(group: DraftGroup) { return ( { - setGroup(groupNumber) - setSelectedUser(undefined) + applyDrop(group.groupNumber) }} > - - {groupNumber}조 ({userList.length}) + + { + setGroupNumberDrafts((prevDrafts) => ({ + ...prevDrafts, + [group.id]: event.target.value, + })) + }} + onBlur={(event) => { + const nextGroupNumber = Number(event.target.value) + renameGroup(group.id, nextGroupNumber) + }} + inputProps={{ min: 1, step: 1 }} + sx={{ width: "60px" }} + /> + 조 ({group.members.length}명) + event.stopPropagation()} + onClick={(event) => { + event.stopPropagation() + removeGroup(group.id) + }} + > + + + + + {group.members.length === 0 ? ( + + 비어 있음 + + ) : ( + group.members.map((user) => renderUserRow(user)) + )} - {userList.map((user) => userGroupRow(user))} ) } - async function setGroup(groupNumber: number) { - if (!selectedUser) { - return - } - selectedUser.groupNumber = groupNumber - await post("/retreat/admin/set-retreat-group", { - selectedUser: selectedUser, - }) - fetchData() - } - function modal() { if (!isShowUserInfo) { return @@ -207,7 +531,7 @@ function GroupFormation() { )} {userAttendInfo.map((info) => ( - + {[, "첫", "둘", "셋"][info.day]}째 날 / {info.time} /{" "} {info.inOutType === "in" ? "들어옴" : "나감"} @@ -217,8 +541,44 @@ function GroupFormation() { } return ( - +
+ + + + 조 편성 + + + 조의 번호를 수정하면 사용자가 한번에 이동합니다. + + + + + + + {modal()} { - setGroup(0) - setSelectedUser(undefined) + applyDrop(0) }} - width="160px" + width="170px" > - 미배정({unassignedUserList.length}명) + 미배정({draftState.unassignedUsers.length}명) - {unassignedUserList.map((user) => unassignedUserRow(user))} + + {draftState.unassignedUsers.map((user) => renderUserRow(user))} + - {new Array(maxGroupNumber).fill(0).map((_, index) => { - const group = groupList[index] - if (!group || group.length === 0) { - return Group(index + 1, []) - } else { - return Group( - group[0].groupNumber, - group.sort((a, b) => a.user.yearOfBirth - b.user.yearOfBirth), - ) - } - })} - {Group(maxGroupNumber + 1, [])} + {draftState.groups.map((group) => renderGroup(group))} {selectedUser && selectedUser.id && ( - {userGroupRow(selectedUser)} + {renderUserRow(selectedUser)} )} diff --git a/client/src/app/retreat/admin/room-assignment/page.tsx b/client/src/app/retreat/admin/room-assignment/page.tsx index 28575182..355adaaf 100644 --- a/client/src/app/retreat/admin/room-assignment/page.tsx +++ b/client/src/app/retreat/admin/room-assignment/page.tsx @@ -1,32 +1,94 @@ "use client" -import { Box, Button, Stack } from "@mui/material" +import { Box, Button, IconButton, Stack, TextField } from "@mui/material" +import AddIcon from "@mui/icons-material/Add" +import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline" +import RestartAltIcon from "@mui/icons-material/RestartAlt" import { useEffect, useState } from "react" -import { get, post } from "../../../../config/api" import { useRouter } from "next/navigation" -import { useSetAtom } from "jotai" +import { get, post } from "../../../../config/api" import { InOutInfo } from "@server/entity/retreat/inOutInfo" import { RetreatAttend } from "@server/entity/retreat/retreatAttend" import Header from "@/components/retreat/admin/Header" import { useNotification } from "@/hooks/useNotification" -function RoomAssingment() { +type DraftRoom = { + id: string + roomNumber: number + members: Array +} + +type DraftState = { + rooms: Array + unassignedUsers: Array +} + +function RoomAssignment() { const { push } = useRouter() - const [unassignedUserList, setUnassignedUserList] = useState( - [] as Array, - ) - const [roomList, setRoomList] = useState([] as Array>) + const { error, success } = useNotification() + const [draftState, setDraftState] = useState({ + rooms: [], + unassignedUsers: [], + }) const [selectedUser, setSelectedUser] = useState() - const [maxRoomNumber, setMaxRoomNumber] = useState(0) + const [mousePoint, setMousePoint] = useState([0, 0]) + const [shiftPosition, setShiftPosition] = useState({ x: 0, y: 0 }) const [isShowUserInfo, setIsShowUserInfo] = useState(false) const [showUserInfo, setShowUserInfo] = useState({} as RetreatAttend) const [userAttendInfo, setUserAttendInfo] = useState([] as Array) + const [gender, setGender] = useState("man") + const [isSaving, setIsSaving] = useState(false) + const [roomNumberDrafts, setRoomNumberDrafts] = useState< + Record + >({}) - const [mousePoint, setMousePoint] = useState([0, 0]) - const [shiftPosition, setShiftPosition] = useState({ x: 0, y: 0 }) + function sortByBirthYear(userList: Array) { + return [...userList].sort((a, b) => a.user.yearOfBirth - b.user.yearOfBirth) + } - const [gender, setGender] = useState("man") - const { error } = useNotification() + function sortRooms(rooms: Array) { + return [...rooms].sort((left, right) => left.roomNumber - right.roomNumber) + } + + function normalizeResponse(response: Array) { + const unassignedUsers = sortByBirthYear( + response.filter( + (retreatAttend) => + !retreatAttend.roomNumber || retreatAttend.roomNumber === 0, + ), + ) + + const roomMap = new Map>() + response + .filter( + (retreatAttend) => + retreatAttend.roomNumber && retreatAttend.roomNumber !== 0, + ) + .forEach((retreatAttend) => { + const currentRoom = roomMap.get(retreatAttend.roomNumber) || [] + roomMap.set(retreatAttend.roomNumber, [...currentRoom, retreatAttend]) + }) + + const rooms = [...roomMap.entries()].map(([roomNumber, members]) => ({ + id: `room-${roomNumber}`, + roomNumber, + members: sortByBirthYear(members), + })) + + return { + rooms: sortRooms(rooms), + unassignedUsers, + } + } + + function syncRoomNumberDrafts(rooms: Array) { + setRoomNumberDrafts( + rooms.reduce>((accumulator, room) => { + accumulator[room.id] = String(room.roomNumber) + return accumulator + }, {}), + ) + } function onMouseMove(event: MouseEvent) { setMousePoint([event.pageX, event.pageY]) @@ -44,112 +106,275 @@ function RoomAssingment() { function fetchData() { get("/retreat/admin/get-room-assignment") .then((response: Array) => { - const unassignedUserList = response - .filter( - (retreatAttend) => - !retreatAttend.roomNumber || retreatAttend.roomNumber === 0, - ) - .sort((a, b) => a.user.yearOfBirth - b.user.yearOfBirth) - setUnassignedUserList(unassignedUserList) - - const room = [] as Array> - const assignedUserList = response.filter( - (retreatAttend) => retreatAttend.roomNumber, - ) - assignedUserList.map((retreatAttend) => { - const roomNumber = retreatAttend.roomNumber - 1 - if (!room[roomNumber]) { - room[roomNumber] = [retreatAttend] - } else { - room[roomNumber].push(retreatAttend) - room[roomNumber].sort( - (a, b) => a.user.yearOfBirth - b.user.yearOfBirth, - ) - } - setRoomList(room) - }) - const maxNumber = Math.max( - ...response.map((retreatAttend) => retreatAttend.roomNumber), - ) - setMaxRoomNumber(maxNumber) + const nextState = normalizeResponse(response) + setDraftState(nextState) + syncRoomNumberDrafts(nextState.rooms) + setSelectedUser(undefined) }) .catch(() => { push("/retreat/admin") error("권한이 없습니다.") - return }) } - function unassignedUserRow(retreatAttend: RetreatAttend) { - return ( - { - setSelectedUser(retreatAttend) - const target = e.target as HTMLElement - const shiftX = e.clientX - target.getBoundingClientRect().left - const shiftY = e.clientY - target.getBoundingClientRect().top - setShiftPosition({ x: shiftX, y: shiftY }) - }} - onMouseUp={() => { - setRoom(0) - setSelectedUser(undefined) - }} - onMouseEnter={() => { - setModal(retreatAttend) - }} - onMouseLeave={() => { - setIsShowUserInfo(false) - }} - sx={{ - justifyContent: "space-between", - backgroundColor: - retreatAttend.user.gender === "man" ? "lightblue" : "pink", - }} - px="4px" - > - - {retreatAttend.user.name}({retreatAttend.user.yearOfBirth}) - {retreatAttend.etc || - (retreatAttend.inOutInfos && retreatAttend.inOutInfos.length) > 0 - ? "*" - : ""} - - + async function persistDraftState(nextState: DraftState) { + const usersById = new Map() + + nextState.unassignedUsers.forEach((user) => { + usersById.set(user.id, user) + }) + nextState.rooms.forEach((room) => { + room.members.forEach((user) => { + usersById.set(user.id, user) + }) + }) + + setIsSaving(true) + + try { + await Promise.all( + [...usersById.values()].map((selectedUser) => + post("/retreat/admin/set-room", { + selectedUser, + }), + ), + ) + } catch { + error("방 저장에 실패했습니다.") + } finally { + setIsSaving(false) + } + } + + function moveUserToRoom( + state: DraftState, + userId: string, + nextRoomNumber: number, + ) { + const currentRoomNumber = state.unassignedUsers.some( + (user) => user.id === userId, + ) + ? 0 + : state.rooms.find((room) => + room.members.some((user) => user.id === userId), + )?.roomNumber || 0 + + if (currentRoomNumber === nextRoomNumber) { + return state + } + + const nextState: DraftState = { + rooms: state.rooms.map((room) => ({ + ...room, + members: [...room.members], + })), + unassignedUsers: [...state.unassignedUsers], + } + + let movingUser: RetreatAttend | undefined + + nextState.unassignedUsers = nextState.unassignedUsers.filter((user) => { + if (user.id === userId) { + movingUser = user + return false + } + + return true + }) + + if (!movingUser) { + for (const room of nextState.rooms) { + const userIndex = room.members.findIndex((user) => user.id === userId) + if (userIndex !== -1) { + movingUser = room.members.splice(userIndex, 1)[0] + break + } + } + } + + if (!movingUser) { + return state + } + + const movedUser = { ...movingUser, roomNumber: nextRoomNumber } + + if (nextRoomNumber === 0) { + nextState.unassignedUsers = sortByBirthYear([ + ...nextState.unassignedUsers, + movedUser, + ]) + } else { + const targetRoom = nextState.rooms.find( + (room) => room.roomNumber === nextRoomNumber, + ) + + if (targetRoom) { + targetRoom.members = sortByBirthYear([...targetRoom.members, movedUser]) + } else { + nextState.rooms.push({ + id: `room-${nextRoomNumber}-${movingUser.id}`, + roomNumber: nextRoomNumber, + members: [movedUser], + }) + } + } + + nextState.rooms = sortRooms( + nextState.rooms.map((room) => ({ + ...room, + members: sortByBirthYear(room.members), + })), ) + nextState.unassignedUsers = sortByBirthYear(nextState.unassignedUsers) + + return nextState } - function modal() { - if (!isShowUserInfo) { - return + function applyDrop(nextRoomNumber: number) { + if (!selectedUser) { + return } - if (!showUserInfo.etc && userAttendInfo.length === 0) { - return + + const nextState = moveUserToRoom( + draftState, + selectedUser.id, + nextRoomNumber, + ) + if (nextState === draftState) { + setSelectedUser(undefined) + return } - return ( - - {showUserInfo.etc} - {showUserInfo.etc && userAttendInfo.length > 0 && ( - - )} - {userAttendInfo.map((info) => ( - - {[, "첫", "둘", "셋"][info.day]}째 날 / {info.time} /{" "} - {info.inOutType === "in" ? "들어옴" : "나감"} - - ))} - + + setDraftState(nextState) + syncRoomNumberDrafts(nextState.rooms) + void persistDraftState(nextState) + setSelectedUser(undefined) + } + + function addRoom() { + setDraftState((prevState) => { + const usedNumbers = new Set( + prevState.rooms.map((room) => room.roomNumber), + ) + let nextRoomNumber = 1 + + while (usedNumbers.has(nextRoomNumber)) { + nextRoomNumber += 1 + } + + const nextState = { + ...prevState, + rooms: sortRooms([ + ...prevState.rooms, + { + id: `room-${nextRoomNumber}-${Date.now()}`, + roomNumber: nextRoomNumber, + members: [], + }, + ]), + } + + syncRoomNumberDrafts(nextState.rooms) + return nextState + }) + } + + function removeRoom(roomId: string) { + const targetRoom = draftState.rooms.find((room) => room.id === roomId) + if (!targetRoom) { + return + } + + const nextState: DraftState = { + rooms: draftState.rooms.filter((room) => room.id !== roomId), + unassignedUsers: sortByBirthYear([ + ...draftState.unassignedUsers, + ...targetRoom.members.map((user) => ({ + ...user, + roomNumber: 0, + })), + ]), + } + + setDraftState(nextState) + syncRoomNumberDrafts(nextState.rooms) + void persistDraftState(nextState) + } + + function renameRoom(roomId: string, nextRoomNumber: number) { + if (!Number.isFinite(nextRoomNumber) || nextRoomNumber <= 0) { + const currentRoom = draftState.rooms.find((room) => room.id === roomId) + setRoomNumberDrafts((prevDrafts) => ({ + ...prevDrafts, + [roomId]: String(currentRoom?.roomNumber || ""), + })) + return + } + + const sourceRoom = draftState.rooms.find((room) => room.id === roomId) + if (!sourceRoom || sourceRoom.roomNumber === nextRoomNumber) { + setRoomNumberDrafts((prevDrafts) => ({ + ...prevDrafts, + [roomId]: String(sourceRoom?.roomNumber || nextRoomNumber), + })) + return + } + + const targetRoom = draftState.rooms.find( + (room) => room.roomNumber === nextRoomNumber && room.id !== roomId, ) + + let nextRooms: Array + + if (targetRoom) { + const mergedMembers = sortByBirthYear([ + ...targetRoom.members, + ...sourceRoom.members.map((user) => ({ + ...user, + roomNumber: nextRoomNumber, + })), + ]) + + nextRooms = sortRooms( + draftState.rooms + .filter((room) => room.id !== roomId) + .map((room) => + room.id === targetRoom.id + ? { + ...room, + roomNumber: nextRoomNumber, + members: mergedMembers, + } + : room, + ), + ) + } else { + nextRooms = sortRooms( + draftState.rooms.map((room) => + room.id === roomId + ? { + ...room, + roomNumber: nextRoomNumber, + members: sortByBirthYear( + room.members.map((user) => ({ + ...user, + roomNumber: nextRoomNumber, + })), + ), + } + : room, + ), + ) + } + + const nextState = { + ...draftState, + rooms: nextRooms, + } + + setDraftState(nextState) + syncRoomNumberDrafts(nextRooms) + void persistDraftState(nextState) } function setModal(retreatAttend: RetreatAttend) { @@ -158,16 +383,16 @@ function RoomAssingment() { setUserAttendInfo(retreatAttend.inOutInfos) } - function userRoomRow(retreatAttend: RetreatAttend) { + function renderUserRow(retreatAttend: RetreatAttend) { return ( { + key={retreatAttend.id} + onMouseDown={(event) => { setSelectedUser(retreatAttend) - const target = e.target as HTMLElement - const shiftX = e.clientX - target.getBoundingClientRect().left - const shiftY = e.clientY - target.getBoundingClientRect().top + const target = event.currentTarget as HTMLElement + const shiftX = event.clientX - target.getBoundingClientRect().left + const shiftY = event.clientY - target.getBoundingClientRect().top setShiftPosition({ x: shiftX, y: shiftY }) }} onMouseEnter={() => { @@ -176,64 +401,139 @@ function RoomAssingment() { onMouseLeave={() => { setIsShowUserInfo(false) }} - width="160px" sx={{ justifyContent: "space-between", backgroundColor: retreatAttend.user.gender === "man" ? "lightblue" : "pink", + borderRadius: "6px", + cursor: "grab", + userSelect: "none", }} + px="4px" + py="2px" > {retreatAttend.user.name}({retreatAttend.user.yearOfBirth}) - {retreatAttend.etc || + {retreatAttend.user.etc || (retreatAttend.inOutInfos && retreatAttend.inOutInfos.length) > 0 ? "*" : ""} + {retreatAttend.isHalf ? " (부참)" : ""} ) } - function Room(roomNumber: number, userList: Array) { + function renderRoom(room: DraftRoom) { + const visibleMembers = room.members.filter( + (retreatAttend) => retreatAttend.user.gender === gender, + ) + return ( { - setRoom(roomNumber) - setSelectedUser(undefined) + applyDrop(room.roomNumber) }} > - - {roomNumber}번 방 ( - { - userList.filter( - (retreatAttend) => retreatAttend.user.gender === gender, - ).length - } - ) + + { + setRoomNumberDrafts((prevDrafts) => ({ + ...prevDrafts, + [room.id]: event.target.value, + })) + }} + onBlur={(event) => { + const nextRoomNumber = Number(event.target.value) + renameRoom(room.id, nextRoomNumber) + }} + inputProps={{ min: 1, step: 1 }} + sx={{ width: "72px" }} + /> + {visibleMembers.length}명 + event.stopPropagation()} + onClick={(event) => { + event.stopPropagation() + removeRoom(room.id) + }} + > + + + + + {visibleMembers.length === 0 ? ( + + 비어 있음 + + ) : ( + visibleMembers.map((retreatAttend) => renderUserRow(retreatAttend)) + )} - {userList - .filter((retreatAttend) => retreatAttend.user.gender === gender) - .map((retreatAttend) => userRoomRow(retreatAttend))} ) } - async function setRoom(roomNumber: number) { - if (!selectedUser) { - return + function modal() { + if (!isShowUserInfo) { + return } - selectedUser.roomNumber = roomNumber - await post("/retreat/admin/set-room", { - selectedUser: selectedUser, - }) - fetchData() + + if (!showUserInfo.etc && userAttendInfo.length === 0) { + return + } + + return ( + + {showUserInfo.etc} + {showUserInfo.etc && userAttendInfo.length > 0 && ( + + )} + {userAttendInfo.map((info) => ( + + {[, "첫", "둘", "셋"][info.day]}째 날 / {info.time} /{" "} + {info.inOutType === "in" ? "들어옴" : "나감"} + + ))} + + ) } return ( @@ -247,19 +547,34 @@ function RoomAssingment() { alignContent="center" > - + {gender === "man" ? "남자" : "여자"} 방배정 + + - 사람의 이름을 눌러 드래그 드롭으로 또는 사람 후 방을 클릭하여 넣으면 - 됩니다. + 사람의 이름을 눌러 드래그 드롭으로 또는 방 번호를 수정하면 바로 + 저장됩니다.
마우스 오버시 사용자의 정보가 나옵니다.
@@ -287,26 +602,26 @@ function RoomAssingment() { borderRadius: "8px", boxShadow: "2px 2px 5px 3px #ACACAC;", border: "1px solid #ACACAC", - paddingBottom: "20px", + padding: "4px", }} onMouseUp={() => { - setRoom(0) - setSelectedUser(undefined) + applyDrop(0) }} - width="160px" + gap="4px" + width="170px" > 미배정( { - unassignedUserList.filter( + draftState.unassignedUsers.filter( (roomNumber) => roomNumber.user.gender === gender, ).length } 명) - {unassignedUserList + {draftState.unassignedUsers .filter((roomNumber) => roomNumber.user.gender === gender) - .map((roomNumber) => unassignedUserRow(roomNumber))} + .map((roomNumber) => renderUserRow(roomNumber))}
- {new Array(maxRoomNumber).fill(0).map((_, index) => { - const room = roomList[index] - if (!room || room.length === 0) { - return Room(index + 1, []) - } else { - return Room(room[0].roomNumber, room) - } - })} - {Room(maxRoomNumber + 1, [])} + {draftState.rooms.map((room) => renderRoom(room))}
{selectedUser && selectedUser.id && ( @@ -337,7 +644,7 @@ function RoomAssingment() { left={mousePoint[0] - shiftPosition.x} style={{ pointerEvents: "none" }} > - {userRoomRow(selectedUser)} + {renderUserRow(selectedUser)}
)}
@@ -345,4 +652,4 @@ function RoomAssingment() { ) } -export default RoomAssingment +export default RoomAssignment diff --git a/server/src/routes/retreat/admin/retreatGroup.ts b/server/src/routes/retreat/admin/retreatGroup.ts index 9351bfc2..db919802 100644 --- a/server/src/routes/retreat/admin/retreatGroup.ts +++ b/server/src/routes/retreat/admin/retreatGroup.ts @@ -20,6 +20,7 @@ router.get("/get-retreat-group-formation", async (req, res) => { etc: true, groupNumber: true, inOutInfos: true, + isHalf: true, user: { id: true, name: true, diff --git a/server/src/routes/retreat/admin/room.ts b/server/src/routes/retreat/admin/room.ts index 2a7670cd..26b7f033 100644 --- a/server/src/routes/retreat/admin/room.ts +++ b/server/src/routes/retreat/admin/room.ts @@ -25,6 +25,7 @@ router.get("/get-room-assignment", async (req, res) => { etc: true, inOutInfos: true, roomNumber: true, + isHalf: true, user: { id: true, name: true,