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 (
+
+ );
+}
+
+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}
);