Skip to content

Commit 6b4f684

Browse files
authored
Merge pull request #33 from team-washer/feat/user-machine-list
[user] 세탁기 · 건조기 목록과 배치도, 예약하기 v1 디자인 이식
2 parents 3343114 + 80f14a8 commit 6b4f684

9 files changed

Lines changed: 803 additions & 2 deletions

File tree

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { getReserveBlockMessage } from "./lib/reserveBlockMessage";
2+
export { default as ReserveMachineButton } from "./ui/ReserveMachineButton";
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { ReserveBlockReason } from "@/entities/reservation";
2+
3+
const formatClock = (value: string | null): string | null => {
4+
if (!value) return null;
5+
const date = new Date(value);
6+
if (Number.isNaN(date.getTime())) return null;
7+
return date.toLocaleTimeString("ko-KR", {
8+
hour: "2-digit",
9+
minute: "2-digit",
10+
hour12: false,
11+
});
12+
};
13+
14+
export function getReserveBlockMessage(
15+
reason: ReserveBlockReason,
16+
penaltyExpiresAt: string | null,
17+
): string {
18+
switch (reason) {
19+
case "MACHINE_UNAVAILABLE":
20+
return "지금은 예약할 수 없는 기기입니다";
21+
case "BANNED":
22+
return "호실 세탁이 금지되어 예약할 수 없습니다";
23+
case "PENALTY": {
24+
const until = formatClock(penaltyExpiresAt);
25+
return until
26+
? `패널티로 ${until}까지 예약할 수 없습니다`
27+
: "계정이 정지되어 예약할 수 없습니다";
28+
}
29+
case "ALREADY_RESERVED":
30+
return "이미 활성화된 예약이 있습니다";
31+
case "ROOM_TYPE_TAKEN":
32+
return "이미 호실에 활성화된 예약이 있습니다";
33+
}
34+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"use client";
2+
3+
import { CheckCircle, Loader2 } from "lucide-react";
4+
import { toast } from "sonner";
5+
import type { UserMachine } from "@/entities/machine";
6+
import {
7+
RESERVED_TIMEOUT_MINUTES,
8+
type ReserveBlockReason,
9+
usePostReservation,
10+
} from "@/entities/reservation";
11+
import { Button } from "@/shared/ui/button";
12+
import { getReserveBlockMessage } from "../lib/reserveBlockMessage";
13+
14+
interface ReserveMachineButtonProps {
15+
machine: UserMachine;
16+
// 예약 가능 여부를 아직 확인하지 못했으면 undefined
17+
blockReason: ReserveBlockReason | null | undefined;
18+
penaltyExpiresAt: string | null;
19+
}
20+
21+
export default function ReserveMachineButton({
22+
machine,
23+
blockReason,
24+
penaltyExpiresAt,
25+
}: ReserveMachineButtonProps) {
26+
const { mutate, isPending } = usePostReservation();
27+
const isRestricted = blockReason === "BANNED" || blockReason === "PENALTY";
28+
29+
const handleReserve = () => {
30+
if (blockReason) {
31+
toast.error("예약 불가", {
32+
description: getReserveBlockMessage(blockReason, penaltyExpiresAt),
33+
});
34+
return;
35+
}
36+
37+
mutate(machine.id, {
38+
onSuccess: () => {
39+
toast.success("예약 성공", {
40+
description: `${machine.type === "WASHER" ? "세탁기" : "건조기"}가 예약되었습니다. ${RESERVED_TIMEOUT_MINUTES}분 이내에 기기를 시작해주세요.`,
41+
});
42+
},
43+
onError: (error) => {
44+
toast.error("예약 실패", { description: error.message });
45+
},
46+
});
47+
};
48+
49+
return (
50+
<Button
51+
onClick={handleReserve}
52+
disabled={isPending || blockReason !== null}
53+
className="flex-1 bg-[#86A9FF] py-2 text-sm text-white hover:bg-[#6487DB] disabled:cursor-not-allowed disabled:opacity-50"
54+
>
55+
{isPending ? (
56+
<>
57+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
58+
예약 중...
59+
</>
60+
) : (
61+
<>
62+
<CheckCircle className="mr-2 h-4 w-4" />
63+
{isRestricted ? "정지됨" : "예약하기"}
64+
</>
65+
)}
66+
</Button>
67+
);
68+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { UserMachine } from "@/entities/machine";
2+
3+
export type MachineCardStatusKey =
4+
| "AVAILABLE"
5+
| "RESERVED"
6+
| "IN_USE"
7+
| "CLEANING"
8+
| "UNAVAILABLE"
9+
| "BROKEN";
10+
11+
export interface MachineCardStatus {
12+
key: MachineCardStatusKey;
13+
color: string;
14+
text: string;
15+
}
16+
17+
// v1 사용자 웹의 기기 상태 배지 색과 문구를 따른다.
18+
export function getMachineCardStatus(machine: UserMachine): MachineCardStatus {
19+
if (machine.condition === "MALFUNCTION") {
20+
return { key: "BROKEN", color: "bg-red-500", text: "고장" };
21+
}
22+
23+
switch (machine.availability) {
24+
case "AVAILABLE":
25+
return { key: "AVAILABLE", color: "bg-green-500", text: "사용가능" };
26+
case "RESERVED":
27+
return { key: "RESERVED", color: "bg-yellow-500", text: "예약됨" };
28+
case "IN_USE":
29+
return { key: "IN_USE", color: "bg-blue-500", text: "사용중" };
30+
case "CLEANING":
31+
return { key: "CLEANING", color: "bg-cyan-500", text: "통세척 중" };
32+
case "UNAVAILABLE":
33+
return { key: "UNAVAILABLE", color: "bg-gray-500", text: "사용 불가" };
34+
}
35+
}
36+
37+
export const MACHINE_STATUS_LEGEND: Pick<
38+
MachineCardStatus,
39+
"color" | "text"
40+
>[] = [
41+
{ color: "bg-green-500", text: "사용가능" },
42+
{ color: "bg-yellow-500", text: "예약됨" },
43+
{ color: "bg-blue-500", text: "사용중" },
44+
{ color: "bg-cyan-500", text: "통세척 중" },
45+
{ color: "bg-red-500", text: "고장" },
46+
];
47+
48+
export function getPositionCode(machine: UserMachine): string | null {
49+
if (!machine.placement) return null;
50+
const side = machine.placement.position === "LEFT" ? "L" : "R";
51+
return `${side}${machine.placement.number}`;
52+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"use client";
2+
3+
import { Map as MapIcon, Shirt, Wind } from "lucide-react";
4+
import type { MachinePosition, UserMachine } from "@/entities/machine";
5+
import { Badge } from "@/shared/ui/badge";
6+
import { Button } from "@/shared/ui/button";
7+
import {
8+
Dialog,
9+
DialogContent,
10+
DialogDescription,
11+
DialogHeader,
12+
DialogTitle,
13+
DialogTrigger,
14+
} from "@/shared/ui/dialog";
15+
import {
16+
getMachineCardStatus,
17+
MACHINE_STATUS_LEGEND,
18+
} from "../lib/machineCardStatus";
19+
20+
interface LayoutModalProps {
21+
floor: number;
22+
// 해당 층의 세탁기와 건조기
23+
machines: UserMachine[];
24+
}
25+
26+
const MIN_SLOTS_PER_SIDE = 3;
27+
28+
export default function LayoutModal({ floor, machines }: LayoutModalProps) {
29+
const maxNumber = Math.max(
30+
MIN_SLOTS_PER_SIDE,
31+
...machines.map((machine) => machine.placement?.number ?? 0),
32+
);
33+
// 입구에서 먼 번호부터 위에 둔다 (예: 3 → 2 → 1)
34+
const numbers = Array.from(
35+
{ length: maxNumber },
36+
(_, index) => maxNumber - index,
37+
);
38+
39+
const findMachine = (
40+
position: MachinePosition,
41+
number: number,
42+
type: UserMachine["type"],
43+
) =>
44+
machines.find(
45+
(machine) =>
46+
machine.type === type &&
47+
machine.placement?.position === position &&
48+
machine.placement.number === number,
49+
);
50+
51+
const renderColumn = (position: MachinePosition) => {
52+
const sideCode = position === "LEFT" ? "L" : "R";
53+
54+
return (
55+
<div className="space-y-4">
56+
<h4 className="mb-2 text-center font-medium text-[#6487DB]">
57+
{position === "LEFT" ? "왼쪽" : "오른쪽"}
58+
</h4>
59+
{numbers.map((number) => (
60+
<div key={number} className="space-y-2">
61+
<MachineSlot
62+
machine={findMachine(position, number, "DRYER")}
63+
placeholder={`D${sideCode}${number}`}
64+
icon="DRYER"
65+
/>
66+
<MachineSlot
67+
machine={findMachine(position, number, "WASHER")}
68+
placeholder={`W${sideCode}${number}`}
69+
icon="WASHER"
70+
/>
71+
</div>
72+
))}
73+
</div>
74+
);
75+
};
76+
77+
return (
78+
<Dialog>
79+
<DialogTrigger asChild>
80+
<Button
81+
variant="outline"
82+
size="sm"
83+
className="border-[#86A9FF] text-[#6487DB] hover:bg-[#EDF2FF]"
84+
>
85+
<MapIcon className="mr-1 h-4 w-4" />
86+
배치도
87+
</Button>
88+
</DialogTrigger>
89+
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto sm:max-w-2xl">
90+
<DialogHeader>
91+
<DialogTitle className="text-[#6487DB]">
92+
{floor}층 세탁실 배치도
93+
</DialogTitle>
94+
<DialogDescription>
95+
세탁기는 아래, 건조기는 위에 배치되어 있습니다.
96+
</DialogDescription>
97+
</DialogHeader>
98+
99+
<div className="space-y-6">
100+
<div className="flex flex-wrap justify-center gap-2">
101+
{MACHINE_STATUS_LEGEND.map(({ color, text }) => (
102+
<Badge key={text} className={`${color} text-white`}>
103+
{text}
104+
</Badge>
105+
))}
106+
</div>
107+
108+
<div
109+
className="relative rounded-xl p-6 pb-12"
110+
style={{
111+
background:
112+
"linear-gradient(135deg, #f0f4ff 0%, #e6f0ff 50%, #dae8ff 100%)",
113+
boxShadow: "inset 0 2px 10px rgba(100, 135, 219, 0.1)",
114+
}}
115+
>
116+
<div className="mx-auto grid max-w-md grid-cols-2 gap-8">
117+
{renderColumn("LEFT")}
118+
{renderColumn("RIGHT")}
119+
</div>
120+
121+
<div className="absolute bottom-3 left-1/2 -translate-x-1/2">
122+
<div className="rounded-full bg-[#86A9FF] px-3 py-1 text-xs font-medium text-white">
123+
입구
124+
</div>
125+
</div>
126+
</div>
127+
</div>
128+
</DialogContent>
129+
</Dialog>
130+
);
131+
}
132+
133+
interface MachineSlotProps {
134+
machine?: UserMachine;
135+
placeholder: string;
136+
icon: UserMachine["type"];
137+
}
138+
139+
function MachineSlot({ machine, placeholder, icon }: MachineSlotProps) {
140+
const Icon = icon === "DRYER" ? Wind : Shirt;
141+
const status = machine ? getMachineCardStatus(machine) : null;
142+
143+
return (
144+
<div
145+
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 ${
146+
status ? `${status.color} text-white` : "text-gray-400"
147+
}`}
148+
style={{
149+
background: status
150+
? undefined
151+
: "linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%)",
152+
boxShadow: status
153+
? "0 4px 12px rgba(0,0,0,0.15)"
154+
: "0 2px 6px rgba(0,0,0,0.1)",
155+
}}
156+
>
157+
<Icon className="mr-1 h-4 w-4 shrink-0" />
158+
<span className="truncate px-1">
159+
{machine ? machine.name : placeholder}
160+
</span>
161+
{machine && (
162+
<div className="absolute -top-1 -right-1">
163+
<div className="h-3 w-3 animate-pulse rounded-full bg-white/30" />
164+
</div>
165+
)}
166+
</div>
167+
);
168+
}

0 commit comments

Comments
 (0)