diff --git a/src/entities/reservation/api/createProxyReservation.ts b/src/entities/reservation/api/createProxyReservation.ts new file mode 100644 index 0000000..e6d7719 --- /dev/null +++ b/src/entities/reservation/api/createProxyReservation.ts @@ -0,0 +1,16 @@ +import { normalizeApiError, post, reservationUrl } from "@/shared/api"; + +interface CreateProxyReservationParams { + userId: number; + machineId: number; +} + +export async function createProxyReservation( + params: CreateProxyReservationParams, +): Promise { + try { + await post(reservationUrl.createProxyReservation(), params); + } catch (error) { + throw normalizeApiError(error); + } +} diff --git a/src/entities/reservation/api/index.ts b/src/entities/reservation/api/index.ts index 89816ff..3909c3c 100644 --- a/src/entities/reservation/api/index.ts +++ b/src/entities/reservation/api/index.ts @@ -1,4 +1,6 @@ +export { createProxyReservation } from "./createProxyReservation"; export { deleteReservation } from "./deleteReservation"; export { useDeleteReservation } from "./useDeleteReservation"; export { useGetMachineReservationHistory } from "./useGetMachineReservationHistory"; export { useGetReservations } from "./useGetReservations"; +export { usePostProxyReservation } from "./usePostProxyReservation"; diff --git a/src/entities/reservation/api/usePostProxyReservation.ts b/src/entities/reservation/api/usePostProxyReservation.ts new file mode 100644 index 0000000..6994c8f --- /dev/null +++ b/src/entities/reservation/api/usePostProxyReservation.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { + dashboardQueryKeys, + machineQueryKeys, + reservationQueryKeys, +} from "@/shared/api"; +import { createProxyReservation } from "./createProxyReservation"; + +export const usePostProxyReservation = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createProxyReservation, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: reservationQueryKeys.all }), + queryClient.invalidateQueries({ queryKey: machineQueryKeys.all }), + queryClient.invalidateQueries({ queryKey: dashboardQueryKeys.all }), + ]); + }, + }); +}; diff --git a/src/features/reservation/create-proxy-reservation/index.ts b/src/features/reservation/create-proxy-reservation/index.ts new file mode 100644 index 0000000..7238a0b --- /dev/null +++ b/src/features/reservation/create-proxy-reservation/index.ts @@ -0,0 +1,2 @@ +export { default as CreateProxyReservationModal } from "./ui/CreateProxyReservationModal"; +export { default as ProxyReservationButton } from "./ui/ProxyReservationButton"; diff --git a/src/features/reservation/create-proxy-reservation/lib/getProxyReservationUserParams.ts b/src/features/reservation/create-proxy-reservation/lib/getProxyReservationUserParams.ts new file mode 100644 index 0000000..e2c18e0 --- /dev/null +++ b/src/features/reservation/create-proxy-reservation/lib/getProxyReservationUserParams.ts @@ -0,0 +1,56 @@ +import type { ManagedUserItem, UserParamsType } from "@/entities/user"; + +export const PROXY_RESERVATION_USER_PAGE_SIZE = 20; + +export function isProxyReservationRoomPrefixSearch(search: string): boolean { + return /^\d{1,2}$/.test(search.trim()); +} + +export function getProxyReservationUserParams( + search: string, +): UserParamsType[] { + const term = search.trim(); + + if (!term) return [{}]; + + if (/^\d+$/.test(term)) { + if (term.length <= 3) { + const roomParams: UserParamsType = + term.length === 3 ? { roomNumber: term } : { floor: Number(term[0]) }; + + return [roomParams, { studentId: term }]; + } + + return [{ studentId: term }]; + } + + return [{ name: term }]; +} + +export function mergeProxyReservationUsers( + groups: ManagedUserItem[][], + search: string, +): ManagedUserItem[] { + const term = search.trim(); + const isRoomPrioritySearch = /^\d{1,3}$/.test(term); + + if (isRoomPrioritySearch && groups.length > 1) { + const [roomCandidates, ...studentGroups] = groups; + const roomMatches = roomCandidates.filter((user) => + user.room.startsWith(term), + ); + + return Array.from( + new Map( + [...roomMatches, ...studentGroups.flat()].map((user) => [ + user.id, + user, + ]), + ).values(), + ); + } + + return Array.from( + new Map(groups.flat().map((user) => [user.id, user])).values(), + ); +} diff --git a/src/features/reservation/create-proxy-reservation/ui/CreateProxyReservationModal.tsx b/src/features/reservation/create-proxy-reservation/ui/CreateProxyReservationModal.tsx new file mode 100644 index 0000000..4bc780f --- /dev/null +++ b/src/features/reservation/create-proxy-reservation/ui/CreateProxyReservationModal.tsx @@ -0,0 +1,251 @@ +"use client"; + +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { X } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import type { MachineItem } from "@/entities/machine"; +import { usePostProxyReservation } from "@/entities/reservation"; +import { getUsers, type ManagedUserItem } from "@/entities/user"; +import { userQueryKeys } from "@/shared/api"; +import { STALE_TIME } from "@/shared/constants/queryOptions"; +import { useOutsideClick } from "@/shared/hooks/useOutsideClick"; +import { FilterSearchField } from "@/shared/ui/admin/Filter"; +import { + getProxyReservationUserParams, + isProxyReservationRoomPrefixSearch, + mergeProxyReservationUsers, + PROXY_RESERVATION_USER_PAGE_SIZE, +} from "../lib/getProxyReservationUserParams"; + +interface CreateProxyReservationModalProps { + machine: MachineItem; + onClose: () => void; + side?: "left" | "right"; +} + +export default function CreateProxyReservationModal({ + machine, + onClose, + side = "right", +}: CreateProxyReservationModalProps) { + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [selectedUserId, setSelectedUserId] = useState(null); + const panelRef = useRef(null); + + const queryParams = useMemo( + () => + getProxyReservationUserParams(debouncedSearch).map((params) => ({ + ...params, + size: params.size ?? PROXY_RESERVATION_USER_PAGE_SIZE, + })), + [debouncedSearch], + ); + const { + data: users = [], + isLoading, + isError, + } = useQuery({ + staleTime: STALE_TIME.USER, + queryKey: [ + ...userQueryKeys.all, + "proxy-reservation-search", + queryParams, + ] as const, + queryFn: async () => { + const isRoomPrefixSearch = + isProxyReservationRoomPrefixSearch(debouncedSearch); + const groups = await Promise.all( + queryParams.map(async (params, index) => { + if (!isRoomPrefixSearch || index !== 0) { + return getUsers(params); + } + + const floorUsers: ManagedUserItem[] = []; + + for (let page = 0; ; page += 1) { + const pageUsers = await getUsers({ + ...params, + page, + size: PROXY_RESERVATION_USER_PAGE_SIZE, + }); + floorUsers.push(...pageUsers); + + if (pageUsers.length < PROXY_RESERVATION_USER_PAGE_SIZE) { + return floorUsers; + } + } + }), + ); + + return mergeProxyReservationUsers(groups, debouncedSearch); + }, + placeholderData: keepPreviousData, + }); + const { mutate: createProxyReservation, isPending } = + usePostProxyReservation(); + + const selectedUser = users.find((user) => user.id === selectedUserId); + const machineTypeLabel = machine.type === "WASHER" ? "세탁기" : "건조기"; + + const handleSearchChange = (value: string) => { + setSearch(value); + setSelectedUserId(null); + }; + + const handleSubmit = () => { + if (!selectedUser || isPending) return; + + createProxyReservation( + { userId: selectedUser.id, machineId: machine.id }, + { + onSuccess: () => { + toast.success("대리 예약에 성공했습니다."); + onClose(); + }, + onError: (error) => { + console.error("Proxy reservation creation failed:", error); + toast.error(error.message || "대리 예약 생성에 실패했습니다."); + }, + }, + ); + }; + + useOutsideClick( + panelRef, + () => { + if (!isPending) onClose(); + }, + true, + ); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedSearch(search); + }, 300); + + return () => clearTimeout(handler); + }, [search]); + + const overlayPositionClass = + side === "right" + ? "absolute left-[calc(100%+16px)] top-0 h-full w-full" + : "absolute right-[calc(100%+16px)] top-0 h-full w-full"; + + return ( +
+
+
+

+ {machine.name} 대리 예약 +

+ + +
+ +
+

예약 기기

+
+

+ {machine.name} · {machineTypeLabel} +

+
+
+ +
+

사용자 선택

+ + +
+ {isLoading ? ( +
+ 사용자 정보를 불러오는 중입니다... +
+ ) : isError ? ( +
+ 사용자 정보를 불러오지 못했습니다. +
+ ) : users.length === 0 ? ( +
+ 검색 결과가 없습니다. +
+ ) : ( +
+ {users.map((user) => { + const isSelected = selectedUserId === user.id; + + return ( + + ); + })} +
+ )} +
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/features/reservation/create-proxy-reservation/ui/ProxyReservationButton.tsx b/src/features/reservation/create-proxy-reservation/ui/ProxyReservationButton.tsx new file mode 100644 index 0000000..0ecb4da --- /dev/null +++ b/src/features/reservation/create-proxy-reservation/ui/ProxyReservationButton.tsx @@ -0,0 +1,26 @@ +import { UserPlus } from "lucide-react"; +import StatusRowActionButton from "@/shared/ui/admin/StatusRowActionButton"; + +interface ProxyReservationButtonProps { + machineName: string; + disabled?: boolean; + onClick: () => void; +} + +export default function ProxyReservationButton({ + machineName, + disabled = false, + onClick, +}: ProxyReservationButtonProps) { + return ( + + + + ); +} diff --git a/src/shared/api/apiUrls.ts b/src/shared/api/apiUrls.ts index 73f8ebb..e9132aa 100644 --- a/src/shared/api/apiUrls.ts +++ b/src/shared/api/apiUrls.ts @@ -22,6 +22,7 @@ export const machineUrl = { export const reservationUrl = { getReservations: () => "/api/v2/admin/reservations", + createProxyReservation: () => "/api/v2/admin/reservations", getReservationDetail: (id: number) => `/api/v2/reservations/${id}`, getMachineReservationHistory: () => "/api/v2/admin/reservations/machines/history", diff --git a/src/shared/ui/admin/StatusRowActionButton.tsx b/src/shared/ui/admin/StatusRowActionButton.tsx new file mode 100644 index 0000000..5a23e06 --- /dev/null +++ b/src/shared/ui/admin/StatusRowActionButton.tsx @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; +import { cn } from "@/shared/lib/cn"; + +interface StatusRowActionButtonProps { + children: ReactNode; + ariaLabel: string; + title?: string; + onClick?: () => void; + disabled?: boolean; + className?: string; +} + +export default function StatusRowActionButton({ + children, + ariaLabel, + title, + onClick, + disabled = false, + className, +}: StatusRowActionButtonProps) { + return ( + + ); +} diff --git a/src/shared/ui/admin/StatusRowActions.tsx b/src/shared/ui/admin/StatusRowActions.tsx index 1923e74..61fb52d 100644 --- a/src/shared/ui/admin/StatusRowActions.tsx +++ b/src/shared/ui/admin/StatusRowActions.tsx @@ -1,8 +1,10 @@ import { Gavel, History } from "lucide-react"; import type { ReactNode } from "react"; +import StatusRowActionButton from "./StatusRowActionButton"; interface StatusRowActionsProps { badge: ReactNode; + extraAction?: ReactNode; onHistory?: () => void; onDelete?: () => void; disabled?: boolean; @@ -10,6 +12,7 @@ interface StatusRowActionsProps { export default function StatusRowActions({ badge, + extraAction, onHistory, onDelete, disabled = false, @@ -18,23 +21,25 @@ export default function StatusRowActions({
{badge} - + - +
); } diff --git a/src/widgets/machines-page/ui/MachineStatusPanel.tsx b/src/widgets/machines-page/ui/MachineStatusPanel.tsx index a3789c9..dc55a06 100644 --- a/src/widgets/machines-page/ui/MachineStatusPanel.tsx +++ b/src/widgets/machines-page/ui/MachineStatusPanel.tsx @@ -8,6 +8,10 @@ import { type ReservationItem, useGetReservations, } from "@/entities/reservation"; +import { + CreateProxyReservationModal, + ProxyReservationButton, +} from "@/features/reservation/create-proxy-reservation"; import { useRemainingTime } from "@/shared/hooks/useRemainingTime"; import StatusPanelShell from "@/shared/ui/admin/StatusPanelShell"; import StatusRowActions from "@/shared/ui/admin/StatusRowActions"; @@ -37,11 +41,13 @@ function MachineRow({ machine, reservations, onHistory, + onProxyReservation, onManage, }: { machine: MachineItem; reservations: ReservationItem[]; onHistory: () => void; + onProxyReservation: () => void; onManage: () => void; }) { const { warningMessage, timeTarget, secondaryInfo } = @@ -87,6 +93,13 @@ function MachineRow({ } onHistory={onHistory} + extraAction={ + + } onDelete={onManage} /> @@ -105,6 +118,8 @@ export default function MachineStatusPanel({ const [selectedMachine, setSelectedMachine] = useState( null, ); + const [selectedProxyMachine, setSelectedProxyMachine] = + useState(null); const { data: reservations } = useGetReservations(); @@ -116,16 +131,27 @@ export default function MachineStatusPanel({ key={machine.id} machine={machine} reservations={reservations ?? []} - onHistory={() => + onHistory={() => { + setSelectedMachine(null); + setSelectedProxyMachine(null); setSelectedHistoryMachineName((prev) => prev === machine.name ? null : machine.name, - ) - } - onManage={() => + ); + }} + onProxyReservation={() => { + setSelectedHistoryMachineName(null); + setSelectedMachine(null); + setSelectedProxyMachine((prev) => + prev?.id === machine.id ? null : machine, + ); + }} + onManage={() => { + setSelectedHistoryMachineName(null); + setSelectedProxyMachine(null); setSelectedMachine((prev) => prev?.id === machine.id ? null : machine, - ) - } + ); + }} /> ))} @@ -136,6 +162,14 @@ export default function MachineStatusPanel({ side={side} /> + {selectedProxyMachine && ( + setSelectedProxyMachine(null)} + side={side} + /> + )} + setSelectedMachine(null)}