diff --git a/app/components/molecules/ThreeDigitsInput.tsx b/app/components/molecules/ThreeDigitsInput.tsx new file mode 100644 index 00000000..3c0be6a5 --- /dev/null +++ b/app/components/molecules/ThreeDigitsInput.tsx @@ -0,0 +1,28 @@ +import { REGEXP_ONLY_DIGITS } from "input-otp"; +import { + type ComponentPropsWithoutRef, + type ElementRef, + forwardRef, +} from "react"; +import { InputOTP, InputOTPGroup, InputOTPSlot } from "../ui/input-otp"; + +// 3桁の数字を入力するためのコンポーネント +const ThreeDigitsInput = forwardRef< + ElementRef, + Omit< + ComponentPropsWithoutRef, + "maxLength" | "pattern" | "render" + > +>(({ ...props }, ref) => { + return ( + + + + + + + + ); +}); + +export { ThreeDigitsInput }; diff --git a/app/components/organisms/DiscountInput.tsx b/app/components/organisms/DiscountInput.tsx new file mode 100644 index 00000000..05ca5599 --- /dev/null +++ b/app/components/organisms/DiscountInput.tsx @@ -0,0 +1,30 @@ +import { + type ComponentPropsWithoutRef, + type ElementRef, + forwardRef, +} from "react"; +import type { WithId } from "~/lib/typeguard"; +import type { OrderEntity } from "~/models/order"; +import { ThreeDigitsInput } from "../molecules/ThreeDigitsInput"; + +// 割引券番号を入力するためのコンポーネント +const DiscountInput = forwardRef< + ElementRef, + ComponentPropsWithoutRef & { + discountOrder: WithId | undefined; + lastPurchasedCups: number; + } +>(({ discountOrder, lastPurchasedCups, ...props }, ref) => { + return ( +
+

割引券番号

+ +

+ {discountOrder === undefined ? "見つかりません" : null} + {discountOrder && `有効杯数: ${lastPurchasedCups}`} +

+
+ ); +}); + +export { DiscountInput }; diff --git a/app/components/organisms/ItemAssign.tsx b/app/components/organisms/ItemAssign.tsx new file mode 100644 index 00000000..5d99b445 --- /dev/null +++ b/app/components/organisms/ItemAssign.tsx @@ -0,0 +1,96 @@ +import { + type Dispatch, + type SetStateAction, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import type { WithId } from "~/lib/typeguard"; +import { cn } from "~/lib/utils"; +import { type ItemEntity, type2label } from "~/models/item"; +import { Input } from "../ui/input"; + +type props = { + item: WithId; + idx: number; + setOrderItems: Dispatch[]>>; + focus: boolean; +}; + +const ItemAssign = ({ item, idx, setOrderItems, focus }: props) => { + const [edit, setEdit] = useState(false); + const [assignee, setAssinee] = useState(null); + + const assignInputRef = useRef(null); + + const closeAssignInput = useCallback(() => { + setOrderItems((prevItems) => { + const newItems = [...prevItems]; + newItems[idx].assignee = assignee; + return newItems; + }); + setEdit(false); + }, [idx, assignee, setOrderItems]); + + // edit の状態に応じて assign 入力欄を開くか閉じる + const change = useCallback(() => { + if (edit) { + closeAssignInput(); + } else { + setEdit(true); + } + }, [edit, closeAssignInput]); + + // focus が変化したときに assign 入力欄を閉じる + useEffect(() => { + if (!focus) { + closeAssignInput(); + } + }, [focus, closeAssignInput]); + + // Enter が押されたときに assign 入力欄を開く + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.key === "Enter") { + change(); + } + }; + if (focus) { + window.addEventListener("keydown", handler); + } + return () => { + window.removeEventListener("keydown", handler); + }; + }, [focus, change]); + + // edit が true に変化したとき assign 入力欄にフォーカスする + useEffect(() => { + if (edit) { + assignInputRef.current?.focus(); + } + }, [edit]); + + return ( +
+

{idx + 1}

+
+

{item.name}

+

{item.price}

+

{type2label[item.type]}

+ {edit ? ( + setAssinee(e.target.value || null)} + placeholder="指名" + /> + ) : ( +

{item.assignee ?? "指名なし"}

+ )} +
+
+ ); +}; + +export { ItemAssign }; diff --git a/app/components/organisms/OrderAlertDialog.tsx b/app/components/organisms/OrderAlertDialog.tsx new file mode 100644 index 00000000..a19c4a10 --- /dev/null +++ b/app/components/organisms/OrderAlertDialog.tsx @@ -0,0 +1,70 @@ +import { type ComponentPropsWithoutRef, forwardRef } from "react"; +import { type2label } from "~/models/item"; +import type { OrderEntity } from "~/models/order"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "../ui/alert-dialog"; + +// 確定前にオーダーの内容を表示するダイアログ +const OrderAlertDialog = forwardRef< + null, + ComponentPropsWithoutRef & { + order: OrderEntity; + chargeView: number | string; + onConfirm: () => void; + } +>(({ order, chargeView, onConfirm, ...props }) => { + return ( + + + + オーダーを確定しますか? + + 以下の内容で提出します + + {order.items.map((item, idx) => ( + + {`${idx + 1} ―― ${item.name} ¥${item.price} ${type2label[item.type]}`} + + ))} + + 合計金額: ¥{order.total} + + {order.discountInfo.previousOrderId !== null && ( + + 割引: -¥{order.discountInfo.discount} + + )} + + 支払金額: ¥{order.billingAmount} + + + お預かり金額: ¥{order.received} + + + お釣り: ¥{chargeView} + + + 備考: {order.description} + + + Tabで選択し、Enterで確定 + + + + キャンセル + 確定 + + + + ); +}); + +export { OrderAlertDialog }; diff --git a/app/components/organisms/OrderItemView.tsx b/app/components/organisms/OrderItemView.tsx new file mode 100644 index 00000000..1806047a --- /dev/null +++ b/app/components/organisms/OrderItemView.tsx @@ -0,0 +1,51 @@ +import type { WithId } from "~/lib/typeguard"; +import type { ItemEntity } from "~/models/item"; +import type { OrderEntity } from "~/models/order"; +import { ItemAssign } from "./ItemAssign"; + +type props = { + order: OrderEntity; + setOrderItems: React.Dispatch[]>>; + inputStatus: "items" | "discount" | "received" | "description" | "submit"; + itemFocus: number; + setItemFocus: React.Dispatch>; + discountOrder: boolean; +}; + +// オーダーのアイテムや割引情報を表示するコンポーネント +const OrderItemView = ({ + inputStatus, + discountOrder, + setOrderItems, + itemFocus, + order, +}: props) => { + return ( + <> + {inputStatus === "items" && ( + <> +

商品を追加: キーボードの a, s, d, f, g, h, j, k, l, ;

+

↑・↓でアイテムのフォーカスを移動

+

Enterで指名の入力欄を開く

+ + )} + {order.items.map((item, idx) => ( + + ))} + {discountOrder && ( +
+

割引

+
-¥{order.discountInfo.discount}
+
+ )} + + ); +}; + +export { OrderItemView }; diff --git a/app/components/pages/CashierV2.tsx b/app/components/pages/CashierV2.tsx new file mode 100644 index 00000000..5afc9a66 --- /dev/null +++ b/app/components/pages/CashierV2.tsx @@ -0,0 +1,275 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Input } from "~/components/ui/input"; +import type { WithId } from "~/lib/typeguard"; +import { type ItemEntity, type2label } from "~/models/item"; +import { OrderEntity } from "~/models/order"; +import { DiscountInput } from "../organisms/DiscountInput"; +import { OrderAlertDialog } from "../organisms/OrderAlertDialog"; +import { OrderItemView } from "../organisms/OrderItemView"; +import { Button } from "../ui/button"; + +const keys = ["a", "s", "d", "f", "g", "h", "j", "k", "l", ";"]; + +const InputStatus = [ + "discount", + "items", + "received", + "description", + "submit", +] as const; + +type props = { + items: WithId[] | undefined; + orders: WithId[] | undefined; + submitPayload: (order: OrderEntity) => void; +}; + +const CashierV2 = ({ items, orders, submitPayload }: props) => { + const [orderItems, setOrderItems] = useState[]>([]); + const [received, setReceived] = useState(""); + const [discountOrderId, setDiscountOrderId] = useState(""); + const [description, setDescription] = useState(""); + const [inputStatus, setInputStatus] = + useState<(typeof InputStatus)[number]>("discount"); + const [dialogOpen, setDialogOpen] = useState(false); + const [itemFocus, setItemFocus] = useState(0); + + const discountOrderIdNum = Number(discountOrderId); + const discountOrder = orders?.find( + (order) => order.orderId === discountOrderIdNum, + ); + const lastPurchasedCups = discountOrder?._getCoffeeCount() ?? 0; + + const curOrderId = + orders?.reduce((acc, cur) => Math.max(acc, cur.orderId), 0) ?? 0; + const nextOrderId = curOrderId + 1; + const newOrder = OrderEntity.createNew({ orderId: nextOrderId }); + const receivedNum = Number(received); + newOrder.items = orderItems; + newOrder.received = receivedNum; + if (description !== "") { + newOrder.description = description; + } + if (discountOrder) { + newOrder.applyDiscount(discountOrder); + } + const charge = newOrder.received - newOrder.billingAmount; + const chargeView: string | number = charge < 0 ? "不足しています" : charge; + + const receivedDOM = useRef(null); + const descriptionDOM = useRef(null); + const discountInputDOM = useRef(null); + + const proceedItemFocus = useCallback(() => { + setItemFocus((prev) => (prev + 1) % orderItems.length); + }, [orderItems]); + + const prevousItemFocus = useCallback(() => { + setItemFocus((prev) => (prev - 1 + orderItems.length) % orderItems.length); + }, [orderItems]); + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (inputStatus !== "items") { + return; + } + if (event.key === "ArrowUp") { + prevousItemFocus(); + } + if (event.key === "ArrowDown") { + proceedItemFocus(); + } + }; + window.addEventListener("keydown", handler); + return () => { + window.removeEventListener("keydown", handler); + }; + }, [proceedItemFocus, prevousItemFocus, inputStatus]); + + const proceedStatus = useCallback(() => { + const idx = InputStatus.indexOf(inputStatus); + setInputStatus(InputStatus[(idx + 1) % InputStatus.length]); + }, [inputStatus]); + + const prevousStatus = useCallback(() => { + const idx = InputStatus.indexOf(inputStatus); + setInputStatus( + InputStatus[(idx - 1 + InputStatus.length) % InputStatus.length], + ); + }, [inputStatus]); + + const submitOrder = useCallback(() => { + if (charge < 0) { + return; + } + if (orderItems.length === 0) { + return; + } + submitPayload(newOrder); + setOrderItems([]); + setReceived(""); + setDiscountOrderId(""); + setDescription(""); + setInputStatus("discount"); + }, [charge, newOrder, orderItems, submitPayload]); + + const moveFocus = useCallback(() => { + switch (inputStatus) { + case "discount": + setDialogOpen(false); + discountInputDOM.current?.focus(); + setItemFocus(-1); + break; + case "items": + break; + case "received": + setItemFocus(-1); + receivedDOM.current?.focus(); + break; + case "description": + descriptionDOM.current?.focus(); + setDialogOpen(false); + break; + case "submit": + setDialogOpen(true); + break; + } + }, [inputStatus]); + + useEffect(moveFocus); + + const keyEventHandlers = useMemo(() => { + return { + ArrowRight: proceedStatus, + ArrowLeft: prevousStatus, + Escape: () => { + setInputStatus("discount"); + setDialogOpen(false); + setOrderItems([]); + setReceived(""); + setDiscountOrderId(""); + setDescription(""); + }, + }; + }, [proceedStatus, prevousStatus]); + + useEffect(() => { + const handlers = items?.map((item, idx) => { + const handler = (event: KeyboardEvent) => { + if (inputStatus !== "items") { + return; + } + if (event.key === keys[idx]) { + setOrderItems((prevItems) => [...prevItems, structuredClone(item)]); + } + }; + return handler; + }); + for (const handler of handlers ?? []) { + window.addEventListener("keydown", handler); + } + + return () => { + for (const handler of handlers ?? []) { + window.removeEventListener("keydown", handler); + } + }; + }, [items, inputStatus]); + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + const key = event.key; + for (const [keyName, keyHandler] of Object.entries(keyEventHandlers)) { + if (key === keyName) { + keyHandler(); + } + } + }; + window.addEventListener("keydown", handler); + return () => { + window.removeEventListener("keydown", handler); + }; + }, [keyEventHandlers]); + + return ( + <> +
+
+ {items?.map((item) => ( +
+

{item.name}

+

{item.price}

+

{type2label[item.type]}

+ +
+ ))} +
+
+

操作

+

入力ステータスを移動して一つ一つの項目を入力していきます

+
    +
  • 入力ステータスを移動 ←・→
  • +
  • 注文をクリア: Esc
  • +
+ + +

{`No. ${nextOrderId}`}

+
+

合計金額

+

{newOrder.billingAmount}

+
+ setDiscountOrderId(value)} + disabled={inputStatus !== "discount"} + discountOrder={discountOrder} + lastPurchasedCups={lastPurchasedCups} + /> + setReceived(e.target.value)} + placeholder="お預かり金額を入力" + disabled={inputStatus !== "received"} + ref={receivedDOM} + /> + + setDescription(e.target.value)} + placeholder="備考" + disabled={inputStatus !== "description"} + ref={descriptionDOM} + /> +
+
+

入力ステータス: {inputStatus}

+ +
+
+ + + ); +}; + +export { CashierV2 }; diff --git a/app/components/ui/input-otp.tsx b/app/components/ui/input-otp.tsx index fc957e3a..ea82f11f 100644 --- a/app/components/ui/input-otp.tsx +++ b/app/components/ui/input-otp.tsx @@ -39,7 +39,7 @@ const InputOTPSlot = React.forwardRef<
; - idx: number; - setOrderItems: Dispatch[]>>; - focus: boolean; -}) => { - const [edit, setEdit] = useState(false); - const [assignee, setAssinee] = useState(null); - - const assignInputRef = useRef(null); - - const closeAssignInput = useCallback(() => { - setOrderItems((prevItems) => { - const newItems = [...prevItems]; - newItems[idx].assignee = assignee; - return newItems; - }); - setEdit(false); - }, [idx, assignee, setOrderItems]); - - const change = useCallback(() => { - if (edit) { - closeAssignInput(); - } else { - setEdit(true); - } - }, [edit, closeAssignInput]); - - useEffect(() => { - if (!focus) { - closeAssignInput(); - } - }, [focus, closeAssignInput]); - - useEffect(() => { - const handler = (event: KeyboardEvent) => { - if (event.key === "Enter") { - change(); - } - }; - if (focus) { - window.addEventListener("keydown", handler); - } - return () => { - window.removeEventListener("keydown", handler); - }; - }, [focus, change]); - - useEffect(() => { - if (edit) { - assignInputRef.current?.focus(); - } - }, [edit]); - - return ( -
-

{idx + 1}

-
-

{item.name}

-

{item.price}

-

{type2label[item.type]}

- {edit ? ( - setAssinee(e.target.value || null)} - placeholder="指名" - /> - ) : ( -

{item.assignee ?? "指名なし"}

- )} -
-
- ); -}; - export default function Cashier() { const { data: items } = useSWRSubscription( "items", @@ -139,313 +20,19 @@ export default function Cashier() { collectionSub({ converter: orderConverter }), ); const submit = useSubmit(); - const [orderItems, setOrderItems] = useState[]>([]); - const [received, setReceived] = useState(""); - const [discountOrderId, setDiscountOrderId] = useState(""); - const [description, setDescription] = useState(""); - const [inputStatus, setInputStatus] = - useState<(typeof InputStatus)[number]>("discount"); - const [DialogOpen, setDialogOpen] = useState(false); - const [itemFocus, setItemFocus] = useState(0); - const discountOrderIdNum = Number(discountOrderId); - const discountOrder = orders?.find( - (order) => order.orderId === discountOrderIdNum, + const submitPayload = useCallback( + (newOrder: OrderEntity) => { + submit( + { newOrder: JSON.stringify(newOrder.toOrder()) }, + { method: "POST" }, + ); + }, + [submit], ); - const lastPurchasedCups = discountOrder?._getCoffeeCount() ?? 0; - - const curOrderId = - orders?.reduce((acc, cur) => Math.max(acc, cur.orderId), 0) ?? 0; - const nextOrderId = curOrderId + 1; - const newOrder = OrderEntity.createNew({ orderId: nextOrderId }); - const receivedNum = Number(received); - newOrder.items = orderItems; - newOrder.received = receivedNum; - if (description !== "") { - newOrder.description = description; - } - if (discountOrder) { - newOrder.applyDiscount(discountOrder); - } - const charge = newOrder.received - newOrder.billingAmount; - const chargeView: string | number = charge < 0 ? "不足しています" : charge; - - const receivedDOM = useRef(null); - const descriptionDOM = useRef(null); - - const proceedItemFocus = useCallback(() => { - setItemFocus((prev) => (prev + 1) % orderItems.length); - }, [orderItems]); - - const prevousItemFocus = useCallback(() => { - setItemFocus((prev) => (prev - 1 + orderItems.length) % orderItems.length); - }, [orderItems]); - - useEffect(() => { - const handler = (event: KeyboardEvent) => { - if (inputStatus !== "items") { - return; - } - if (event.key === "ArrowUp") { - prevousItemFocus(); - } - if (event.key === "ArrowDown") { - proceedItemFocus(); - } - }; - window.addEventListener("keydown", handler); - return () => { - window.removeEventListener("keydown", handler); - }; - }, [proceedItemFocus, prevousItemFocus, inputStatus]); - - const proceedStatus = useCallback(() => { - const idx = InputStatus.indexOf(inputStatus); - setInputStatus(InputStatus[(idx + 1) % InputStatus.length]); - }, [inputStatus]); - - const prevousStatus = useCallback(() => { - const idx = InputStatus.indexOf(inputStatus); - setInputStatus( - InputStatus[(idx - 1 + InputStatus.length) % InputStatus.length], - ); - }, [inputStatus]); - - const submitOrder = useCallback(() => { - if (charge < 0) { - return; - } - if (orderItems.length === 0) { - return; - } - submit( - { newOrder: JSON.stringify(newOrder.toOrder()) }, - { method: "POST" }, - ); - setOrderItems([]); - setReceived(""); - setDiscountOrderId(""); - setDescription(""); - setInputStatus("discount"); - }, [charge, newOrder, orderItems, submit]); - - const moveFocus = useCallback(() => { - switch (inputStatus) { - case "discount": - setDialogOpen(false); - document.getElementById("discountOrderId")?.focus(); - setItemFocus(-1); - break; - case "items": - break; - case "received": - setItemFocus(-1); - receivedDOM.current?.focus(); - break; - case "description": - descriptionDOM.current?.focus(); - setDialogOpen(false); - break; - case "submit": - setDialogOpen(true); - break; - } - }, [inputStatus]); - - useEffect(moveFocus); - - const keyEventHandlers = useMemo(() => { - return { - ArrowRight: proceedStatus, - ArrowLeft: prevousStatus, - Escape: () => { - setInputStatus("discount"); - setDialogOpen(false); - setOrderItems([]); - setReceived(""); - setDiscountOrderId(""); - setDescription(""); - }, - }; - }, [proceedStatus, prevousStatus]); - - useEffect(() => { - const handlers = items?.map((item, idx) => { - const handler = (event: KeyboardEvent) => { - if (inputStatus !== "items") { - return; - } - if (event.key === keys[idx]) { - setOrderItems((prevItems) => [...prevItems, structuredClone(item)]); - } - }; - return handler; - }); - for (const handler of handlers ?? []) { - window.addEventListener("keydown", handler); - } - - return () => { - for (const handler of handlers ?? []) { - window.removeEventListener("keydown", handler); - } - }; - }, [items, inputStatus]); - - useEffect(() => { - const handler = (event: KeyboardEvent) => { - const key = event.key; - for (const [keyName, keyHandler] of Object.entries(keyEventHandlers)) { - if (key === keyName) { - keyHandler(); - } - } - }; - window.addEventListener("keydown", handler); - return () => { - window.removeEventListener("keydown", handler); - }; - }, [keyEventHandlers]); return ( - <> -
-
- {items?.map((item) => ( -
-

{item.name}

-

{item.price}

-

{type2label[item.type]}

- -
- ))} -
-
-

操作

-

入力ステータスを移動して一つ一つの項目を入力していきます

-
    -
  • 入力ステータスを移動 ←・→
  • -
  • 注文をクリア: Esc
  • -
- - -

{`No. ${nextOrderId}`}

-
-

合計金額

-

{newOrder.billingAmount}

-
-
-

割引券番号

- setDiscountOrderId(value)} - disabled={inputStatus !== "discount"} - > - - - - - -

- {discountOrder === undefined ? "見つかりません" : null} - {discountOrder && `有効杯数: ${lastPurchasedCups}`} -

-
- setReceived(e.target.value)} - placeholder="お預かり金額を入力" - disabled={inputStatus !== "received"} - ref={receivedDOM} - /> - - setDescription(e.target.value)} - placeholder="備考" - disabled={inputStatus !== "description"} - ref={descriptionDOM} - /> -
-
-

入力ステータス: {inputStatus}

- {inputStatus === "items" && ( - <> -

商品を追加: キーボードの a, s, d, f, g, h, j, k, l, ;

-

↑・↓でアイテムのフォーカスを移動

-

Enterで指名の入力欄を開く

- - )} - {orderItems.map((item, idx) => ( - - ))} - {discountOrder && ( -
-

割引

-
-¥{newOrder.discountInfo.discount}
-
- )} -
-
- - - - オーダーを確定しますか? - - 以下の内容で提出します - - {orderItems.map((item, idx) => ( - - {`${idx + 1} ―― ${item.name} ¥${item.price} ${type2label[item.type]}`} - - ))} - - 合計金額: ¥{newOrder.total} - - {discountOrder && ( - - 割引: -¥{newOrder.discountInfo.discount} - - )} - - 支払金額: ¥{newOrder.billingAmount} - - - お預かり金額: ¥{receivedNum} - - - お釣り: ¥{chargeView} - - 備考: {description} - - Tabで選択し、Enterで確定 - - - - - キャンセル - - 確定 - - - - + ); }