diff --git a/src/features/reservation/reserve-machine/index.ts b/src/features/reservation/reserve-machine/index.ts new file mode 100644 index 0000000..4c18b91 --- /dev/null +++ b/src/features/reservation/reserve-machine/index.ts @@ -0,0 +1,2 @@ +export { getReserveBlockMessage } from "./lib/reserveBlockMessage"; +export { default as ReserveMachineButton } from "./ui/ReserveMachineButton"; diff --git a/src/features/reservation/reserve-machine/lib/reserveBlockMessage.ts b/src/features/reservation/reserve-machine/lib/reserveBlockMessage.ts new file mode 100644 index 0000000..ed6132d --- /dev/null +++ b/src/features/reservation/reserve-machine/lib/reserveBlockMessage.ts @@ -0,0 +1,34 @@ +import type { ReserveBlockReason } from "@/entities/reservation"; + +const formatClock = (value: string | null): string | null => { + if (!value) return null; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + return date.toLocaleTimeString("ko-KR", { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); +}; + +export function getReserveBlockMessage( + reason: ReserveBlockReason, + penaltyExpiresAt: string | null, +): string { + switch (reason) { + case "MACHINE_UNAVAILABLE": + return "지금은 예약할 수 없는 기기입니다"; + case "BANNED": + return "호실 세탁이 금지되어 예약할 수 없습니다"; + case "PENALTY": { + const until = formatClock(penaltyExpiresAt); + return until + ? `패널티로 ${until}까지 예약할 수 없습니다` + : "계정이 정지되어 예약할 수 없습니다"; + } + case "ALREADY_RESERVED": + return "이미 활성화된 예약이 있습니다"; + case "ROOM_TYPE_TAKEN": + return "이미 호실에 활성화된 예약이 있습니다"; + } +} diff --git a/src/features/reservation/reserve-machine/ui/ReserveMachineButton.tsx b/src/features/reservation/reserve-machine/ui/ReserveMachineButton.tsx new file mode 100644 index 0000000..41b7d0a --- /dev/null +++ b/src/features/reservation/reserve-machine/ui/ReserveMachineButton.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { CheckCircle, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import type { UserMachine } from "@/entities/machine"; +import { + RESERVED_TIMEOUT_MINUTES, + type ReserveBlockReason, + usePostReservation, +} from "@/entities/reservation"; +import { Button } from "@/shared/ui/button"; +import { getReserveBlockMessage } from "../lib/reserveBlockMessage"; + +interface ReserveMachineButtonProps { + machine: UserMachine; + // 예약 가능 여부를 아직 확인하지 못했으면 undefined + blockReason: ReserveBlockReason | null | undefined; + penaltyExpiresAt: string | null; +} + +export default function ReserveMachineButton({ + machine, + blockReason, + penaltyExpiresAt, +}: ReserveMachineButtonProps) { + const { mutate, isPending } = usePostReservation(); + const isRestricted = blockReason === "BANNED" || blockReason === "PENALTY"; + + const handleReserve = () => { + if (blockReason) { + toast.error("예약 불가", { + description: getReserveBlockMessage(blockReason, penaltyExpiresAt), + }); + return; + } + + mutate(machine.id, { + onSuccess: () => { + toast.success("예약 성공", { + description: `${machine.type === "WASHER" ? "세탁기" : "건조기"}가 예약되었습니다. ${RESERVED_TIMEOUT_MINUTES}분 이내에 기기를 시작해주세요.`, + }); + }, + onError: (error) => { + toast.error("예약 실패", { description: error.message }); + }, + }); + }; + + return ( + + ); +} diff --git a/src/widgets/user-machine-list/lib/machineCardStatus.ts b/src/widgets/user-machine-list/lib/machineCardStatus.ts new file mode 100644 index 0000000..abed224 --- /dev/null +++ b/src/widgets/user-machine-list/lib/machineCardStatus.ts @@ -0,0 +1,52 @@ +import type { UserMachine } from "@/entities/machine"; + +export type MachineCardStatusKey = + | "AVAILABLE" + | "RESERVED" + | "IN_USE" + | "CLEANING" + | "UNAVAILABLE" + | "BROKEN"; + +export interface MachineCardStatus { + key: MachineCardStatusKey; + color: string; + text: string; +} + +// v1 사용자 웹의 기기 상태 배지 색과 문구를 따른다. +export function getMachineCardStatus(machine: UserMachine): MachineCardStatus { + if (machine.condition === "MALFUNCTION") { + return { key: "BROKEN", color: "bg-red-500", text: "고장" }; + } + + switch (machine.availability) { + case "AVAILABLE": + return { key: "AVAILABLE", color: "bg-green-500", text: "사용가능" }; + case "RESERVED": + return { key: "RESERVED", color: "bg-yellow-500", text: "예약됨" }; + case "IN_USE": + return { key: "IN_USE", color: "bg-blue-500", text: "사용중" }; + case "CLEANING": + return { key: "CLEANING", color: "bg-cyan-500", text: "통세척 중" }; + case "UNAVAILABLE": + return { key: "UNAVAILABLE", color: "bg-gray-500", text: "사용 불가" }; + } +} + +export const MACHINE_STATUS_LEGEND: Pick< + MachineCardStatus, + "color" | "text" +>[] = [ + { color: "bg-green-500", text: "사용가능" }, + { color: "bg-yellow-500", text: "예약됨" }, + { color: "bg-blue-500", text: "사용중" }, + { color: "bg-cyan-500", text: "통세척 중" }, + { color: "bg-red-500", text: "고장" }, +]; + +export function getPositionCode(machine: UserMachine): string | null { + if (!machine.placement) return null; + const side = machine.placement.position === "LEFT" ? "L" : "R"; + return `${side}${machine.placement.number}`; +} diff --git a/src/widgets/user-machine-list/ui/LayoutModal.tsx b/src/widgets/user-machine-list/ui/LayoutModal.tsx new file mode 100644 index 0000000..e216981 --- /dev/null +++ b/src/widgets/user-machine-list/ui/LayoutModal.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { Map as MapIcon, Shirt, Wind } from "lucide-react"; +import type { MachinePosition, UserMachine } from "@/entities/machine"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/shared/ui/dialog"; +import { + getMachineCardStatus, + MACHINE_STATUS_LEGEND, +} from "../lib/machineCardStatus"; + +interface LayoutModalProps { + floor: number; + // 해당 층의 세탁기와 건조기 + machines: UserMachine[]; +} + +const MIN_SLOTS_PER_SIDE = 3; + +export default function LayoutModal({ floor, machines }: LayoutModalProps) { + const maxNumber = Math.max( + MIN_SLOTS_PER_SIDE, + ...machines.map((machine) => machine.placement?.number ?? 0), + ); + // 입구에서 먼 번호부터 위에 둔다 (예: 3 → 2 → 1) + const numbers = Array.from( + { length: maxNumber }, + (_, index) => maxNumber - index, + ); + + const findMachine = ( + position: MachinePosition, + number: number, + type: UserMachine["type"], + ) => + machines.find( + (machine) => + machine.type === type && + machine.placement?.position === position && + machine.placement.number === number, + ); + + const renderColumn = (position: MachinePosition) => { + const sideCode = position === "LEFT" ? "L" : "R"; + + return ( +
+

+ {position === "LEFT" ? "왼쪽" : "오른쪽"} +

+ {numbers.map((number) => ( +
+ + +
+ ))} +
+ ); + }; + + return ( + + + + + + + + {floor}층 세탁실 배치도 + + + 세탁기는 아래, 건조기는 위에 배치되어 있습니다. + + + +
+
+ {MACHINE_STATUS_LEGEND.map(({ color, text }) => ( + + {text} + + ))} +
+ +
+
+ {renderColumn("LEFT")} + {renderColumn("RIGHT")} +
+ +
+
+ 입구 +
+
+
+
+
+
+ ); +} + +interface MachineSlotProps { + machine?: UserMachine; + placeholder: string; + icon: UserMachine["type"]; +} + +function MachineSlot({ machine, placeholder, icon }: MachineSlotProps) { + const Icon = icon === "DRYER" ? Wind : Shirt; + const status = machine ? getMachineCardStatus(machine) : null; + + return ( +
+ + + {machine ? machine.name : placeholder} + + {machine && ( +
+
+
+ )} +
+ ); +} diff --git a/src/widgets/user-machine-list/ui/MachineCard.tsx b/src/widgets/user-machine-list/ui/MachineCard.tsx new file mode 100644 index 0000000..3495c09 --- /dev/null +++ b/src/widgets/user-machine-list/ui/MachineCard.tsx @@ -0,0 +1,194 @@ +"use client"; + +import { AlertTriangle, Clock, Home, MapPin, Shirt, Wind } from "lucide-react"; +import type { ReactNode } from "react"; +import { getJobStateInfo, type UserMachine } from "@/entities/machine"; +import type { ReserveBlockReason } from "@/entities/reservation"; +import { + getReserveBlockMessage, + ReserveMachineButton, +} from "@/features/reservation/reserve-machine"; +import { formatCountdown } from "@/shared/lib"; +import { parseKstDateTime } from "@/shared/lib/kstDateTime"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/ui/card"; +import { Separator } from "@/shared/ui/separator"; +import { + getMachineCardStatus, + getPositionCode, +} from "../lib/machineCardStatus"; + +interface MachineCardProps { + machine: UserMachine; + // mount 전에는 null (카운트다운 숨김) + now: number | null; + // 본인 예약 여부 (룸메이트 예약은 false) + isMyReservation: boolean; + // 우리 호실 예약이면 예약 만료 시각(epoch ms) + myRoomReservedDeadline: number | null; + blockReason: ReserveBlockReason | null | undefined; + penaltyExpiresAt: string | null; + // 고장 신고 · 히스토리 버튼 자리 + actions?: ReactNode; +} + +export default function MachineCard({ + machine, + now, + isMyReservation, + myRoomReservedDeadline, + blockReason, + penaltyExpiresAt, + actions, +}: MachineCardProps) { + const status = getMachineCardStatus(machine); + const operatingStateInfo = getJobStateInfo(machine.type, machine.jobState); + const isWasher = machine.type === "WASHER"; + const Icon = isWasher ? Shirt : Wind; + const positionCode = getPositionCode(machine); + + const completionTime = + parseKstDateTime(machine.expectedCompletionTime)?.getTime() ?? null; + const remaining = + now === null + ? null + : status.key === "IN_USE" && completionTime + ? { + label: isWasher ? "세탁 완료까지" : "건조 완료까지", + ms: completionTime - now, + tone: "text-yellow-600 dark:text-yellow-400", + } + : status.key === "RESERVED" && myRoomReservedDeadline + ? { + label: "예약 만료까지", + ms: myRoomReservedDeadline - now, + tone: "text-orange-600 dark:text-orange-400", + } + : null; + + const showBlockMessage = + status.key === "AVAILABLE" && + blockReason !== null && + blockReason !== undefined && + blockReason !== "MACHINE_UNAVAILABLE"; + + return ( + + +
+ + + {machine.name} + + + {status.text} + +
+ {positionCode && ( + + + {positionCode} 위치 + + )} +
+ + +
+
+ {operatingStateInfo.icon} +
+
{operatingStateInfo.text}
+
+ {operatingStateInfo.description} +
+
+
+
+ + {machine.roomNumber && + (status.key === "RESERVED" || status.key === "IN_USE") && ( +
+ + + {machine.roomNumber}호 {isMyReservation ? "(내 예약)" : "예약"} + +
+ )} + + {remaining && remaining.ms > 0 && ( +
+ + + {remaining.label}: {formatCountdown(remaining.ms)} + +
+ )} + + + +
+ {status.key === "AVAILABLE" && ( + + )} + + {status.key === "BROKEN" && ( +
+

+ 고장으로 사용 불가 +

+
+ )} + + {(status.key === "RESERVED" || + status.key === "IN_USE" || + status.key === "CLEANING" || + status.key === "UNAVAILABLE") && ( + + )} + + {actions} +
+ + {showBlockMessage && ( +
+ + {getReserveBlockMessage(blockReason, penaltyExpiresAt)} +
+ )} +
+
+ ); +} diff --git a/src/widgets/user-machine-list/ui/MachineList.tsx b/src/widgets/user-machine-list/ui/MachineList.tsx new file mode 100644 index 0000000..2b59660 --- /dev/null +++ b/src/widgets/user-machine-list/ui/MachineList.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { RefreshCw, Shirt, Wind } from "lucide-react"; +import { type ReactNode, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + getMachineTypeFromName, + isMachineReservable, + type MachineType, + type UserMachine, + useGetMachineStatuses, +} from "@/entities/machine"; +import { + getReserveBlockReason, + getReservedDeadline, + type ReserveBlockReason, + useGetActiveReservation, + useGetReservationAvailability, + useGetRoomActiveReservations, +} from "@/entities/reservation"; +import { useNow } from "@/shared/hooks/useNow"; +import { parseKstDateTime } from "@/shared/lib/kstDateTime"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { getPositionCode } from "../lib/machineCardStatus"; +import LayoutModal from "./LayoutModal"; +import MachineCard from "./MachineCard"; + +interface MachineListProps { + type: MachineType; + // 기기별 고장 신고 · 히스토리 버튼 + renderActions?: (machine: UserMachine) => ReactNode; +} + +const MACHINE_POLLING_MS = 30_000; +const RESERVATION_POLLING_MS = 10_000; +const REFRESH_COOLDOWN_SECONDS = 5; + +const typeLabel = { WASHER: "세탁기", DRYER: "건조기" } as const; + +export default function MachineList({ type, renderActions }: MachineListProps) { + const now = useNow(); + const [refreshCooldown, setRefreshCooldown] = useState(0); + + const machinesQuery = useGetMachineStatuses({ + refetchInterval: MACHINE_POLLING_MS, + }); + const availabilityQuery = useGetReservationAvailability({ + refetchInterval: MACHINE_POLLING_MS, + }); + const activeReservationQuery = useGetActiveReservation({ + refetchInterval: RESERVATION_POLLING_MS, + }); + const roomReservationsQuery = useGetRoomActiveReservations({ + refetchInterval: RESERVATION_POLLING_MS, + }); + + const allMachines = machinesQuery.data ?? []; + const availability = availabilityQuery.data; + const roomReservations = roomReservationsQuery.data ?? []; + // 호실 예약에는 룸메이트 예약도 포함되므로, 본인 예약은 활성 예약 ID로 판별한다. + const myReservationId = activeReservationQuery.data?.id ?? null; + const isContextReady = + availabilityQuery.isSuccess && + activeReservationQuery.isSuccess && + roomReservationsQuery.isSuccess; + + useEffect(() => { + if (refreshCooldown <= 0) return; + const timer = window.setTimeout( + () => setRefreshCooldown((value) => value - 1), + 1000, + ); + return () => window.clearTimeout(timer); + }, [refreshCooldown]); + + const handleRefresh = async () => { + if (refreshCooldown > 0) return; + + setRefreshCooldown(REFRESH_COOLDOWN_SECONDS); + const results = await Promise.all([ + machinesQuery.refetch(), + availabilityQuery.refetch(), + activeReservationQuery.refetch(), + roomReservationsQuery.refetch(), + ]); + + if (results.some((result) => result.error)) { + toast.error("새로고침 실패", { + description: "데이터를 불러오는 중 오류가 발생했습니다.", + }); + return; + } + toast.success("새로고침 완료", { + description: `${typeLabel[type]} 정보가 업데이트되었습니다.`, + }); + }; + + const getBlockReason = ( + machine: UserMachine, + ): ReserveBlockReason | null | undefined => { + if (!isContextReady || !availability) return undefined; + + return getReserveBlockReason({ + machineReservable: isMachineReservable(machine), + machineType: machine.type, + canReserve: availability.canReserve, + isBanned: availability.isBanned, + hasMyActiveReservation: activeReservationQuery.data !== null, + // 호실 예약 응답에는 기기 종류가 없어 machineId로 기기 목록에서 찾고, 없을 때만 이름으로 추정한다. + roomReservationTypes: roomReservations.map( + (reservation) => + allMachines.find((item) => item.id === reservation.machineId)?.type ?? + getMachineTypeFromName(reservation.machineName), + ), + }); + }; + + const getMyRoomReservedDeadline = (machine: UserMachine): number | null => { + const reservation = roomReservations.find( + (item) => item.machineId === machine.id && item.status === "RESERVED", + ); + return reservation + ? getReservedDeadline(parseKstDateTime(reservation.reservedAt)) + : null; + }; + + const machines = allMachines.filter((machine) => machine.type === type); + const Icon = type === "WASHER" ? Shirt : Wind; + + if (machines.length === 0) { + return ( +
+ +

+ 접근 가능한 {typeLabel[type]}가 없습니다. +

+

관리자에게 문의하세요.

+
+ ); + } + + const machinesByFloor = new Map(); + for (const machine of machines) { + const floor = machine.placement?.floor ?? 0; + machinesByFloor.set(floor, [ + ...(machinesByFloor.get(floor) ?? []), + machine, + ]); + } + const floors = [...machinesByFloor.keys()].sort((a, b) => a - b); + + return ( +
+ {floors.map((floor) => { + const floorMachines = (machinesByFloor.get(floor) ?? []).sort((a, b) => + (getPositionCode(a) ?? a.name).localeCompare( + getPositionCode(b) ?? b.name, + ), + ); + + return ( +
+
+
+

+ {floor > 0 ? `${floor}층 ` : ""} + {typeLabel[type]} +

+ + {floorMachines.length}대 + +
+
+ + {floor > 0 && ( + machine.placement?.floor === floor, + )} + /> + )} +
+
+ +
+ {floorMachines.map((machine) => ( + + ))} +
+
+ ); + })} +
+ ); +} diff --git a/src/widgets/user-machine-list/ui/MachineReservationSection.tsx b/src/widgets/user-machine-list/ui/MachineReservationSection.tsx new file mode 100644 index 0000000..740c0e6 --- /dev/null +++ b/src/widgets/user-machine-list/ui/MachineReservationSection.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Shirt, Wind } from "lucide-react"; +import { useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/shared/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; +import MachineList from "./MachineList"; + +const tabTriggerClassName = + "flex h-8 items-center gap-2 rounded-md px-4 py-2 text-sm transition-all data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm focus-visible:ring-0 focus-visible:ring-offset-0"; + +// v1 홈의 "기기 예약" 카드 (세탁기 / 건조기 탭) +export default function MachineReservationSection() { + const [activeTab, setActiveTab] = useState("washing"); + + return ( + + + 기기 예약 + + 사용하고 싶은 세탁기나 건조기를 선택하세요 + + + + + + + + 세탁기 + + + + 건조기 + + + + + + + + + + + + + + ); +} diff --git a/src/widgets/user-main-page/UserMainPage.tsx b/src/widgets/user-main-page/UserMainPage.tsx index 67afd9a..14dc69d 100644 --- a/src/widgets/user-main-page/UserMainPage.tsx +++ b/src/widgets/user-main-page/UserMainPage.tsx @@ -1,7 +1,7 @@ "use client"; import { AlertCircle, Calendar, RefreshCw } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { type ReactNode, useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; import { isMachineReservable, useGetMachineStatuses } from "@/entities/machine"; import { @@ -27,7 +27,12 @@ const MACHINE_POLLING_MS = 30_000; const RESERVATION_POLLING_MS = 10_000; const REFRESH_COOLDOWN_SECONDS = 5; -export default function UserMainPage() { +interface UserMainPageProps { + // 하단 "기기 예약" 섹션 (세탁기 / 건조기 탭) + machineSection?: ReactNode; +} + +export default function UserMainPage({ machineSection }: UserMainPageProps) { const now = useNow(); const [refreshCooldown, setRefreshCooldown] = useState(0); @@ -206,6 +211,8 @@ export default function UserMainPage() { )} + + {machineSection}
);