Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/features/reservation/reserve-machine/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { getReserveBlockMessage } from "./lib/reserveBlockMessage";
export { default as ReserveMachineButton } from "./ui/ReserveMachineButton";
Original file line number Diff line number Diff line change
@@ -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 "이미 호실에 활성화된 예약이 있습니다";
}
}
Original file line number Diff line number Diff line change
@@ -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 (
<Button
onClick={handleReserve}
disabled={isPending || blockReason !== null}
className="flex-1 bg-[#86A9FF] py-2 text-sm text-white hover:bg-[#6487DB] disabled:cursor-not-allowed disabled:opacity-50"
>
{isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
예약 중...
</>
) : (
<>
<CheckCircle className="mr-2 h-4 w-4" />
{isRestricted ? "정지됨" : "예약하기"}
</>
)}
</Button>
);
}
52 changes: 52 additions & 0 deletions src/widgets/user-machine-list/lib/machineCardStatus.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
168 changes: 168 additions & 0 deletions src/widgets/user-machine-list/ui/LayoutModal.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-4">
<h4 className="mb-2 text-center font-medium text-[#6487DB]">
{position === "LEFT" ? "왼쪽" : "오른쪽"}
</h4>
{numbers.map((number) => (
<div key={number} className="space-y-2">
<MachineSlot
machine={findMachine(position, number, "DRYER")}
placeholder={`D${sideCode}${number}`}
icon="DRYER"
/>
<MachineSlot
machine={findMachine(position, number, "WASHER")}
placeholder={`W${sideCode}${number}`}
icon="WASHER"
/>
</div>
))}
</div>
);
};

return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="border-[#86A9FF] text-[#6487DB] hover:bg-[#EDF2FF]"
>
<MapIcon className="mr-1 h-4 w-4" />
배치도
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="text-[#6487DB]">
{floor}층 세탁실 배치도
</DialogTitle>
<DialogDescription>
세탁기는 아래, 건조기는 위에 배치되어 있습니다.
</DialogDescription>
</DialogHeader>

<div className="space-y-6">
<div className="flex flex-wrap justify-center gap-2">
{MACHINE_STATUS_LEGEND.map(({ color, text }) => (
<Badge key={text} className={`${color} text-white`}>
{text}
</Badge>
))}
</div>

<div
className="relative rounded-xl p-6 pb-12"
style={{
background:
"linear-gradient(135deg, #f0f4ff 0%, #e6f0ff 50%, #dae8ff 100%)",
boxShadow: "inset 0 2px 10px rgba(100, 135, 219, 0.1)",
}}
>
<div className="mx-auto grid max-w-md grid-cols-2 gap-8">
{renderColumn("LEFT")}
{renderColumn("RIGHT")}
</div>

<div className="absolute bottom-3 left-1/2 -translate-x-1/2">
<div className="rounded-full bg-[#86A9FF] px-3 py-1 text-xs font-medium text-white">
입구
</div>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}

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 (
<div
className={`relative flex h-16 transform items-center justify-center rounded-lg border-2 border-gray-300 text-xs font-medium transition-all duration-200 hover:scale-105 hover:shadow-lg ${
status ? `${status.color} text-white` : "text-gray-400"
}`}
style={{
background: status
? undefined
: "linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%)",
boxShadow: status
? "0 4px 12px rgba(0,0,0,0.15)"
: "0 2px 6px rgba(0,0,0,0.1)",
}}
>
<Icon className="mr-1 h-4 w-4 shrink-0" />
<span className="truncate px-1">
{machine ? machine.name : placeholder}
</span>
{machine && (
<div className="absolute -top-1 -right-1">
<div className="h-3 w-3 animate-pulse rounded-full bg-white/30" />
</div>
)}
</div>
);
}
Loading
Loading