diff --git a/app/components/molecules/AttractiveTextBox.tsx b/app/components/molecules/AttractiveTextBox.tsx new file mode 100644 index 00000000..a80ad8c6 --- /dev/null +++ b/app/components/molecules/AttractiveTextBox.tsx @@ -0,0 +1,46 @@ +import { + type ChangeEventHandler, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { Input, type InputProps } from "../ui/input"; + +type props = InputProps & { + onTextSet: (text: string) => void; + focus: boolean; +}; + +// focus が true のときにフォーカスを当てるテキストボックス +const AttractiveTextBox = ({ focus, onTextSet, ...props }: props) => { + const [text, setText] = useState(""); + const DOMRef = useRef(null); + + const onChangeHandler: ChangeEventHandler = useCallback( + (event) => setText(event.target.value), + [], + ); + + useEffect(() => { + onTextSet(text); + }, [text, onTextSet]); + + useEffect(() => { + if (focus) { + DOMRef.current?.focus(); + } + }, [focus]); + + return ( + + ); +}; + +export { AttractiveTextBox }; diff --git a/app/components/organisms/DiscountInput.tsx b/app/components/organisms/DiscountInput.tsx index 05ca5599..58e68f54 100644 --- a/app/components/organisms/DiscountInput.tsx +++ b/app/components/organisms/DiscountInput.tsx @@ -2,26 +2,70 @@ import { type ComponentPropsWithoutRef, type ElementRef, forwardRef, + useEffect, + useMemo, + useState, } from "react"; import type { WithId } from "~/lib/typeguard"; -import type { OrderEntity } from "~/models/order"; +import { OrderEntity } from "~/models/order"; import { ThreeDigitsInput } from "../molecules/ThreeDigitsInput"; +const findByOrderId = ( + orders: WithId[] | undefined, + orderId: number, +): WithId | undefined => { + return orders?.find((order) => order.orderId === orderId); +}; + // 割引券番号を入力するためのコンポーネント const DiscountInput = forwardRef< ElementRef, ComponentPropsWithoutRef & { - discountOrder: WithId | undefined; - lastPurchasedCups: number; + orders: WithId[] | undefined; + onDiscountOrderFind: (discountOrder: WithId) => void; + onDiscountOrderRemoved: () => void; } ->(({ discountOrder, lastPurchasedCups, ...props }, ref) => { +>(({ orders, onDiscountOrderFind, onDiscountOrderRemoved, ...props }, ref) => { + const [discountOrderId, setDiscountOrderId] = useState(""); + + const isComplete = useMemo( + () => discountOrderId.length === 3, + [discountOrderId], + ); + + const discountOrder = useMemo(() => { + if (!isComplete) return; + const discountOrderIdNum = Number(discountOrderId); + return findByOrderId(orders, discountOrderIdNum); + }, [orders, isComplete, discountOrderId]); + + const lastPurchasedCups = useMemo( + () => discountOrder?.getCoffeeCount() ?? 0, + [discountOrder], + ); + + useEffect(() => { + if (isComplete && discountOrder) { + onDiscountOrderFind(discountOrder); + } + return onDiscountOrderRemoved; + }, [isComplete, discountOrder, onDiscountOrderFind, onDiscountOrderRemoved]); + return (

割引券番号

- + setDiscountOrderId(value)} + {...props} + />

- {discountOrder === undefined ? "見つかりません" : null} - {discountOrder && `有効杯数: ${lastPurchasedCups}`} + {!isComplete && "3桁の割引券番号を入力してください"} + {isComplete && + (discountOrder instanceof OrderEntity + ? `有効杯数: ${lastPurchasedCups}` + : "見つかりません")}

); diff --git a/app/components/organisms/ItemAssign.tsx b/app/components/organisms/ItemAssign.tsx index 5d99b445..1d3cfa08 100644 --- a/app/components/organisms/ItemAssign.tsx +++ b/app/components/organisms/ItemAssign.tsx @@ -1,11 +1,4 @@ -import { - type Dispatch, - type SetStateAction, - useCallback, - useEffect, - useRef, - useState, -} from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import type { WithId } from "~/lib/typeguard"; import { cn } from "~/lib/utils"; import { type ItemEntity, type2label } from "~/models/item"; @@ -14,46 +7,49 @@ import { Input } from "../ui/input"; type props = { item: WithId; idx: number; - setOrderItems: Dispatch[]>>; + mutateItem: ( + idx: number, + action: (prev: WithId) => WithId, + ) => void; focus: boolean; }; -const ItemAssign = ({ item, idx, setOrderItems, focus }: props) => { - const [edit, setEdit] = useState(false); +const ItemAssign = ({ item, idx, mutateItem, focus }: props) => { + const [editable, setEditable] = 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; + mutateItem(idx, (prev) => { + const copy = structuredClone(prev); + copy.assignee = assignee; + return copy; }); - setEdit(false); - }, [idx, assignee, setOrderItems]); + setEditable(false); + }, [assignee, idx, mutateItem]); // edit の状態に応じて assign 入力欄を開くか閉じる - const change = useCallback(() => { - if (edit) { + const switchEditable = useCallback(() => { + if (editable) { closeAssignInput(); } else { - setEdit(true); + setEditable(true); } - }, [edit, closeAssignInput]); + }, [editable, closeAssignInput]); // focus が変化したときに assign 入力欄を閉じる useEffect(() => { - if (!focus) { + if (!focus && editable) { closeAssignInput(); } - }, [focus, closeAssignInput]); + }, [focus, editable, closeAssignInput]); // Enter が押されたときに assign 入力欄を開く useEffect(() => { const handler = (event: KeyboardEvent) => { if (event.key === "Enter") { - change(); + switchEditable(); } }; if (focus) { @@ -62,14 +58,14 @@ const ItemAssign = ({ item, idx, setOrderItems, focus }: props) => { return () => { window.removeEventListener("keydown", handler); }; - }, [focus, change]); + }, [focus, switchEditable]); // edit が true に変化したとき assign 入力欄にフォーカスする useEffect(() => { - if (edit) { + if (editable) { assignInputRef.current?.focus(); } - }, [edit]); + }, [editable]); return (
@@ -78,7 +74,7 @@ const ItemAssign = ({ item, idx, setOrderItems, focus }: props) => {

{item.name}

{item.price}

{type2label[item.type]}

- {edit ? ( + {editable ? ( []>>; - inputStatus: "items" | "discount" | "received" | "description" | "submit"; - itemFocus: number; - setItemFocus: React.Dispatch>; + items: WithId[] | undefined; + focus: boolean; + onAddItem: (item: WithId) => void; + mutateItem: ( + idx: number, + action: (prev: WithId) => WithId, + ) => void; discountOrder: boolean; }; // オーダーのアイテムや割引情報を表示するコンポーネント const OrderItemView = ({ - inputStatus, + focus, discountOrder, - setOrderItems, - itemFocus, + onAddItem, + mutateItem, order, + items, }: props) => { + const [itemFocus, setItemFocus] = useState(0); + + const proceedItemFocus = useCallback(() => { + setItemFocus((prev) => (prev + 1) % order.items.length); + }, [order.items]); + + const prevousItemFocus = useCallback(() => { + setItemFocus( + (prev) => (prev - 1 + order.items.length) % order.items.length, + ); + }, [order.items]); + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (!focus) { + return; + } + if (event.key === "ArrowUp") { + prevousItemFocus(); + } + if (event.key === "ArrowDown") { + proceedItemFocus(); + } + }; + window.addEventListener("keydown", handler); + return () => { + window.removeEventListener("keydown", handler); + }; + }, [proceedItemFocus, prevousItemFocus, focus]); + + useEffect(() => { + const handlers = items?.map((item, idx) => { + const handler = (event: KeyboardEvent) => { + if (!focus) { + return; + } + if (event.key === keys[idx]) { + onAddItem(item); + } + }; + return handler; + }); + for (const handler of handlers ?? []) { + window.addEventListener("keydown", handler); + } + + return () => { + for (const handler of handlers ?? []) { + window.removeEventListener("keydown", handler); + } + }; + }, [items, focus, onAddItem]); + return ( <> - {inputStatus === "items" && ( + {focus && ( <>

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

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

@@ -34,7 +94,7 @@ const OrderItemView = ({ key={`${idx}-${item.id}`} item={item} idx={idx} - setOrderItems={setOrderItems} + mutateItem={mutateItem} focus={idx === itemFocus} /> ))} diff --git a/app/components/pages/CashierV2.tsx b/app/components/pages/CashierV2.tsx index 5afc9a66..aab3ffb8 100644 --- a/app/components/pages/CashierV2.tsx +++ b/app/components/pages/CashierV2.tsx @@ -1,15 +1,21 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useReducer, + 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 { AttractiveTextBox } from "../molecules/AttractiveTextBox"; 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", @@ -24,68 +30,116 @@ type props = { submitPayload: (order: OrderEntity) => void; }; +export type Action = + | { type: "clear"; effectFn?: () => void } + | { type: "updateOrderId"; orderId: number } + | { + type: "addItem"; + item: WithId; + } + | { + type: "mutateItem"; + idx: number; + action: (prev: WithId) => WithId; + } + | { type: "applyDiscount"; discountOrder: WithId } + | { type: "removeDiscount" } + | { type: "setReceived"; received: string } + | { type: "setDescription"; description: string }; + +const reducer = (state: OrderEntity, action: Action): OrderEntity => { + const addItem = (item: WithId) => { + const updated = state.clone(); + updated.items = [...updated.items, item]; + return updated; + }; + const applyDiscount = (discountOrder: WithId) => { + const updated = state.clone(); + updated.applyDiscount(discountOrder); + return updated; + }; + const removeDiscount = () => { + const updated = state.clone(); + updated.removeDiscount(); + return updated; + }; + const mutateItem = ( + idx: number, + action: (prev: WithId) => WithId, + ) => { + const updated = state.clone(); + updated.items[idx] = action(updated.items[idx]); + return updated; + }; + const updateOrderId = (orderId: number) => { + const updated = state.clone(); + updated.orderId = orderId; + return updated; + }; + const setReceived = (received: string) => { + const updated = state.clone(); + updated.received = Number(received); + return updated; + }; + const setDescription = (description: string) => { + const updated = state.clone(); + updated.description = description; + return updated; + }; + const clear = (effectFn?: () => void) => { + if (effectFn) { + effectFn(); + } + return OrderEntity.createNew({ orderId: state.orderId }); + }; + + switch (action.type) { + case "clear": + return clear(action.effectFn); + case "applyDiscount": + return applyDiscount(action.discountOrder); + case "removeDiscount": + return removeDiscount(); + case "addItem": + return addItem(action.item); + case "mutateItem": + return mutateItem(action.idx, action.action); + case "setReceived": + return setReceived(action.received); + case "setDescription": + return setDescription(action.description); + case "updateOrderId": + return updateOrderId(action.orderId); + } +}; + +const latestOrderId = (orders: WithId[] | undefined): number => { + if (!orders) { + return 0; + } + return orders.reduce((acc, cur) => Math.max(acc, cur.orderId), 0); +}; + const CashierV2 = ({ items, orders, submitPayload }: props) => { - const [orderItems, setOrderItems] = useState[]>([]); - const [received, setReceived] = useState(""); - const [discountOrderId, setDiscountOrderId] = useState(""); - const [description, setDescription] = useState(""); + const [newOrder, dispatch] = useReducer( + reducer, + OrderEntity.createNew({ orderId: -1 }), + ); const [inputStatus, setInputStatus] = useState<(typeof InputStatus)[number]>("discount"); const [dialogOpen, setDialogOpen] = useState(false); - const [itemFocus, setItemFocus] = useState(0); + const [inputSession, setInputSession] = useState(new Date()); - const discountOrderIdNum = Number(discountOrderId); - const discountOrder = orders?.find( - (order) => order.orderId === discountOrderIdNum, - ); - const lastPurchasedCups = discountOrder?._getCoffeeCount() ?? 0; + const nextOrderId = useMemo(() => latestOrderId(orders) + 1, [orders]); + useEffect(() => { + dispatch({ type: "updateOrderId", orderId: nextOrderId }); + }, [nextOrderId]); - 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]); @@ -102,32 +156,24 @@ const CashierV2 = ({ items, orders, submitPayload }: props) => { if (charge < 0) { return; } - if (orderItems.length === 0) { + if (newOrder.items.length === 0) { return; } + dispatch({ type: "clear", effectFn: () => setInputSession(new Date()) }); submitPayload(newOrder); - setOrderItems([]); - setReceived(""); - setDiscountOrderId(""); - setDescription(""); - setInputStatus("discount"); - }, [charge, newOrder, orderItems, submitPayload]); + }, [charge, newOrder, 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": @@ -145,37 +191,11 @@ const CashierV2 = ({ items, orders, submitPayload }: props) => { Escape: () => { setInputStatus("discount"); setDialogOpen(false); - setOrderItems([]); - setReceived(""); - setDiscountOrderId(""); - setDescription(""); + dispatch({ type: "clear" }); }, }; }, [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; @@ -200,13 +220,6 @@ const CashierV2 = ({ items, orders, submitPayload }: props) => {

{item.name}

{item.price}

{type2label[item.type]}

-
))} @@ -218,46 +231,63 @@ const CashierV2 = ({ items, orders, submitPayload }: props) => {
  • 注文をクリア: Esc
  • - -

    {`No. ${nextOrderId}`}

    +

    {`No. ${newOrder.orderId}`}

    合計金額

    {newOrder.billingAmount}

    setDiscountOrderId(value)} disabled={inputStatus !== "discount"} - discountOrder={discountOrder} - lastPurchasedCups={lastPurchasedCups} + orders={orders} + onDiscountOrderFind={useCallback( + (discountOrder) => + dispatch({ type: "applyDiscount", discountOrder }), + [], + )} + onDiscountOrderRemoved={useCallback( + () => dispatch({ type: "removeDiscount" }), + [], + )} /> - setReceived(e.target.value)} - placeholder="お預かり金額を入力" - disabled={inputStatus !== "received"} - ref={receivedDOM} + onTextSet={useCallback( + (text) => dispatch({ type: "setReceived", received: text }), + [], + )} + focus={inputStatus === "received"} /> - setDescription(e.target.value)} - placeholder="備考" - disabled={inputStatus !== "description"} - ref={descriptionDOM} + dispatch({ type: "setDescription", description: text }), + [], + )} + focus={inputStatus === "description"} />

    入力ステータス: {inputStatus}

    dispatch({ type: "addItem", item }), + [], + )} + mutateItem={useCallback( + (idx, action) => dispatch({ type: "mutateItem", idx, action }), + [], + )} + focus={inputStatus === "items"} + discountOrder={useMemo( + () => newOrder.discountInfo.previousOrderId !== null, + [newOrder], + )} />
    diff --git a/app/models/order.ts b/app/models/order.ts index 68cc67aa..c830a1e8 100644 --- a/app/models/order.ts +++ b/app/models/order.ts @@ -46,8 +46,8 @@ export class OrderEntity implements Order { // 全てのプロパティを private にして外部からの直接アクセスを禁止 private constructor( private readonly _id: string | undefined, - private readonly _orderId: number, - private readonly _createdAt: Date, + private _orderId: number, + private _createdAt: Date, private _servedAt: Date | null, private _items: WithId[], private _total: number, @@ -77,7 +77,11 @@ export class OrderEntity implements Order { ); } - static fromOrder(order: WithId): WithId { + static fromOrder(order: WithId): WithId; + static fromOrder(order: Order): OrderEntity; + static fromOrder( + order: WithId | Order, + ): WithId | OrderEntity { return new OrderEntity( order.id, order.orderId, @@ -90,22 +94,6 @@ export class OrderEntity implements Order { order.billingAmount, order.received, DiscountInfoEntity.fromDiscountInfo(order.discountInfo), - ) as WithId; - } - - static fromOrderWOId(order: Order): OrderEntity { - return new OrderEntity( - undefined, - order.orderId, - order.createdAt, - order.servedAt, - order.items, - order.total, - order.orderReady, - order.description, - order.billingAmount, - order.received, - DiscountInfoEntity.fromDiscountInfo(order.discountInfo), ); } @@ -120,6 +108,9 @@ export class OrderEntity implements Order { get orderId() { return this._orderId; } + set orderId(orderId: number) { + this._orderId = orderId; + } get createdAt() { return this._createdAt; @@ -175,7 +166,7 @@ export class OrderEntity implements Order { // methods // -------------------------------------------------- - _getCoffeeCount() { + getCoffeeCount() { // milk 以外のアイテムの数を返す // TODO(toririm): このメソッドは items が変更された時だけでいい return this.items.filter((item) => item.type !== "milk").length; @@ -191,11 +182,10 @@ export class OrderEntity implements Order { this._servedAt = new Date(); } - /* このメソッドのみで discountInfo を更新する */ applyDiscount(previousOrder: OrderEntity) { const validCups = Math.min( - this._getCoffeeCount(), - previousOrder._getCoffeeCount(), + this.getCoffeeCount(), + previousOrder.getCoffeeCount(), ); const discount = validCups * DISCOUNT_RATE_PER_CUP; @@ -204,7 +194,15 @@ export class OrderEntity implements Order { validCups, discount, }); - return this._discountInfo; + } + + removeDiscount() { + this._discountInfo = new DiscountInfoEntity(null, 0, 0); + } + + nowCreated() { + // createdAt を更新 + this._createdAt = new Date(); } toOrder(): Order { @@ -222,4 +220,10 @@ export class OrderEntity implements Order { discountInfo: this.discountInfo, }; } + + clone(): WithId; + clone(): OrderEntity; + clone(): WithId | OrderEntity { + return OrderEntity.fromOrder(this.toOrder()); + } } diff --git a/app/routes/_header.casher.tsx b/app/routes/_header.casher.tsx index 2060c2fe..702f1f2e 100644 --- a/app/routes/_header.casher.tsx +++ b/app/routes/_header.casher.tsx @@ -204,7 +204,7 @@ export const clientAction: ClientActionFunction = async ({ request }) => { } const { newOrder } = submission.value; - const order = OrderEntity.fromOrderWOId(newOrder); + const order = OrderEntity.fromOrder(newOrder); const savedOrder = await orderRepository.save(order); diff --git a/app/routes/cashier-v2.tsx b/app/routes/cashier-v2.tsx index e9927903..6f4d4207 100644 --- a/app/routes/cashier-v2.tsx +++ b/app/routes/cashier-v2.tsx @@ -52,7 +52,7 @@ export const clientAction: ClientActionFunction = async ({ request }) => { } const { newOrder } = submission.value; - const order = OrderEntity.fromOrderWOId(newOrder); + const order = OrderEntity.fromOrder(newOrder); const savedOrder = await orderRepository.save(order);