diff --git a/common/firebase-utils/converter.ts b/common/firebase-utils/converter.ts index 427fbfe2..d5d88bcc 100644 --- a/common/firebase-utils/converter.ts +++ b/common/firebase-utils/converter.ts @@ -8,6 +8,12 @@ import { import _ from "lodash"; import type { ZodSchema } from "zod"; import type { WithId } from "../lib/typeguard"; +import { + type GlobalCashierState, + MasterStateEntity, + globalCashierStateSchema, + globalMasterStateSchema, +} from "../models/global"; import { ItemEntity, itemSchema } from "../models/item"; import { OrderEntity, orderSchema } from "../models/order"; @@ -95,3 +101,34 @@ export const orderConverter: FirestoreDataConverter> = { return OrderEntity.fromOrder(convertedData); }, }; + +export const cashierStateConverter: FirestoreDataConverter = + { + toFirestore: converter(globalCashierStateSchema).toFirestore, + fromFirestore: ( + snapshot: QueryDocumentSnapshot, + options: SnapshotOptions, + ) => { + const convertedData = converter(globalCashierStateSchema).fromFirestore( + snapshot, + options, + ); + + return convertedData; + }, + }; + +export const masterStateConverter: FirestoreDataConverter = { + toFirestore: converter(globalMasterStateSchema).toFirestore, + fromFirestore: ( + snapshot: QueryDocumentSnapshot, + options: SnapshotOptions, + ) => { + const convertedData = converter(globalMasterStateSchema).fromFirestore( + snapshot, + options, + ); + + return MasterStateEntity.fromMasterState(convertedData); + }, +}; diff --git a/common/firebase-utils/firestore.ts b/common/firebase-utils/firestore.ts index 8f8cbfca..12ce04ac 100644 --- a/common/firebase-utils/firestore.ts +++ b/common/firebase-utils/firestore.ts @@ -1,7 +1,7 @@ -import { initializeApp } from "firebase/app"; -import { getFirestore } from "firebase/firestore"; +import { type FirebaseOptions, initializeApp } from "firebase/app"; +import { getFirestore, initializeFirestore } from "firebase/firestore"; -const firebaseConfig = { +const firebaseConfig: FirebaseOptions = { apiKey: "AIzaSyC3llKAZQOVQEFV0-0xHiseDB55YXJilHM", authDomain: "cafeore-2024.firebaseapp.com", projectId: "cafeore-2024", @@ -12,4 +12,8 @@ const firebaseConfig = { const app = initializeApp(firebaseConfig); +initializeFirestore(app, { + ignoreUndefinedProperties: true, +}); + export const prodDB = getFirestore(app); diff --git a/common/firebase-utils/subscription.ts b/common/firebase-utils/subscription.ts index 155ec67c..3eb589ac 100644 --- a/common/firebase-utils/subscription.ts +++ b/common/firebase-utils/subscription.ts @@ -2,6 +2,7 @@ import { type FirestoreDataConverter, type QueryConstraint, collection, + doc, onSnapshot, query, } from "firebase/firestore"; @@ -34,3 +35,25 @@ export const collectionSub = ( }; return sub; }; + +export const documentSub = ({ + converter, +}: { converter: FirestoreDataConverter }) => { + const sub: SWRSubscription = ( + [collectionName, ...keys], + { next }, + ) => { + const coll = collection(prodDB, collectionName); + const unsub = onSnapshot( + doc(coll, ...keys).withConverter(converter), + (snapshot) => { + next(null, snapshot.data()); + }, + (err) => { + next(err); + }, + ); + return unsub; + }; + return sub; +}; diff --git a/common/models/global.ts b/common/models/global.ts new file mode 100644 index 00000000..b67a7673 --- /dev/null +++ b/common/models/global.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; +import { orderSchema } from "./order"; + +export const globalCashierStateSchema = z.object({ + id: z.literal("cashier-state"), + edittingOrder: orderSchema, +}); + +export type GlobalCashierState = z.infer; + +export const orderStatTypes = ["stop", "operational"] as const; + +export const orderStatSchema = z.object({ + createdAt: z.date(), + type: z.enum(orderStatTypes), +}); + +export type OrderStatType = (typeof orderStatTypes)[number]; +export type OrderStat = z.infer; + +export const globalMasterStateSchema = z.object({ + id: z.literal("master-state"), + orderStats: z.array(orderStatSchema), +}); + +export type GlobalMasterState = z.infer; + +export const globalStatSchema = z.union([ + globalCashierStateSchema, + globalMasterStateSchema, +]); + +export type GlobalStat = z.infer; + +export class MasterStateEntity implements GlobalMasterState { + constructor( + public id: "master-state", + private _orderStats: OrderStat[], + ) {} + + static fromMasterState(state: GlobalMasterState): MasterStateEntity { + return new MasterStateEntity(state.id, state.orderStats); + } + + static createNew(): MasterStateEntity { + const initOrderStat: OrderStat = { + createdAt: new Date(), + type: "operational", + }; + return new MasterStateEntity("master-state", [initOrderStat]); + } + + get orderStats() { + return this._orderStats; + } + + addOrderStat(stat: OrderStatType) { + this._orderStats.push({ + createdAt: new Date(), + type: stat, + }); + } +} diff --git a/common/repositories/global.ts b/common/repositories/global.ts new file mode 100644 index 00000000..da79d4be --- /dev/null +++ b/common/repositories/global.ts @@ -0,0 +1,62 @@ +import { type Firestore, doc, getDoc, setDoc } from "firebase/firestore"; +import { + cashierStateConverter, + masterStateConverter, +} from "../firebase-utils/converter"; +import { prodDB } from "../firebase-utils/firestore"; +import type { GlobalCashierState, MasterStateEntity } from "../models/global"; + +export type CashierStateRepo = { + get: () => Promise; + set: (state: GlobalCashierState) => Promise; +}; + +export type MasterStateRepo = { + get: () => Promise; + set: (state: MasterStateEntity) => Promise; +}; + +export const cashierStateRepoFactory = (db: Firestore): CashierStateRepo => { + return { + get: async () => { + const docRef = doc(db, "global", "cashier-state").withConverter( + cashierStateConverter, + ); + const docSnap = await getDoc(docRef); + const data = docSnap.data(); + if (data?.id === "cashier-state") { + return data; + } + }, + set: async (state) => { + const docRef = doc(db, "global", "cashier-state").withConverter( + cashierStateConverter, + ); + await setDoc(docRef, state); + }, + }; +}; + +export const masterStateRepoFactory = (db: Firestore): MasterStateRepo => { + return { + get: async () => { + const docRef = doc(db, "global", "master-state").withConverter( + masterStateConverter, + ); + const docSnap = await getDoc(docRef); + const data = docSnap.data(); + if (data?.id === "master-state") { + return data; + } + }, + set: async (state) => { + const docRef = doc(db, "global", "master-state").withConverter( + masterStateConverter, + ); + await setDoc(docRef, state); + }, + }; +}; + +export const cashierRepository = cashierStateRepoFactory(prodDB); +export const masterRepository = masterStateRepoFactory(prodDB); diff --git a/firestore.rules b/firestore.rules index e039e4a6..6f0d8b25 100644 --- a/firestore.rules +++ b/firestore.rules @@ -8,5 +8,8 @@ service cloud.firestore { match /orders/{orderId} { allow read, write: if true; } + match /global/{docId} { + allow read, write: if true; + } } -} \ No newline at end of file +} diff --git a/pos/app/components/functional/useSyncCahiserOrder.ts b/pos/app/components/functional/useSyncCahiserOrder.ts new file mode 100644 index 00000000..717d309f --- /dev/null +++ b/pos/app/components/functional/useSyncCahiserOrder.ts @@ -0,0 +1,11 @@ +import type { OrderEntity } from "common/models/order"; +import { useEffect } from "react"; + +export const useSyncCahiserOrder = ( + order: OrderEntity, + syncOrder: (order: OrderEntity) => void, +) => { + useEffect(() => { + syncOrder(order); + }, [order, syncOrder]); +}; diff --git a/pos/app/components/pages/CashierV2.tsx b/pos/app/components/pages/CashierV2.tsx index 9d485f31..544ba4f4 100644 --- a/pos/app/components/pages/CashierV2.tsx +++ b/pos/app/components/pages/CashierV2.tsx @@ -9,6 +9,7 @@ import { useInputStatus } from "../functional/useInputStatus"; import { useLatestOrderId } from "../functional/useLatestOrderId"; import { useOrderState } from "../functional/useOrderState"; import { usePreventNumberKeyUpDown } from "../functional/usePreventNumberKeyUpDown"; +import { useSyncCahiserOrder } from "../functional/useSyncCahiserOrder"; import { useUISession } from "../functional/useUISession"; import { AttractiveTextArea } from "../molecules/AttractiveTextArea"; import { InputHeader } from "../molecules/InputHeader"; @@ -24,6 +25,7 @@ type props = { items: WithId[] | undefined; orders: WithId[] | undefined; submitPayload: (order: OrderEntity) => void; + syncOrder: (order: OrderEntity) => void; }; /** @@ -31,7 +33,7 @@ type props = { * * データの入出力は親コンポーネントに任せる */ -const CashierV2 = ({ items, orders, submitPayload }: props) => { +const CashierV2 = ({ items, orders, submitPayload, syncOrder }: props) => { const [newOrder, newOrderDispatch] = useOrderState(); const { inputStatus, @@ -44,6 +46,7 @@ const CashierV2 = ({ items, orders, submitPayload }: props) => { const [menuOpen, setMenuOpen] = useState(false); const [UISession, renewUISession] = useUISession(); const { nextOrderId } = useLatestOrderId(orders); + useSyncCahiserOrder(newOrder, syncOrder); const printer = usePrinter(); diff --git a/pos/app/routes/_header.master.tsx b/pos/app/routes/_header.master.tsx index fa26347c..8b66a053 100644 --- a/pos/app/routes/_header.master.tsx +++ b/pos/app/routes/_header.master.tsx @@ -5,18 +5,28 @@ import { useSubmit, } from "@remix-run/react"; import { id2abbr } from "common/data/items"; -import { orderConverter } from "common/firebase-utils/converter"; -import { collectionSub } from "common/firebase-utils/subscription"; +import { + masterStateConverter, + orderConverter, +} from "common/firebase-utils/converter"; +import { collectionSub, documentSub } from "common/firebase-utils/subscription"; import { stringToJSONSchema } from "common/lib/custom-zod"; +import { + MasterStateEntity, + type OrderStatType, + orderStatTypes, +} from "common/models/global"; import { OrderEntity, orderSchema } from "common/models/order"; +import { masterRepository } from "common/repositories/global"; import { orderRepository } from "common/repositories/order"; import dayjs from "dayjs"; import { orderBy } from "firebase/firestore"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import useSWRSubscription from "swr/subscription"; import { z } from "zod"; import { InputComment } from "~/components/molecules/InputComment"; import { RealtimeElapsedTime } from "~/components/molecules/RealtimeElapsedTime"; +import { Button } from "~/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; import { cn } from "~/lib/utils"; @@ -37,6 +47,22 @@ export default function FielsOfMaster() { }, [submit], ); + const { data: masterRemoStat } = useSWRSubscription( + ["global", "master-state"], + documentSub({ converter: masterStateConverter }), + ); + const masterStat = masterRemoStat ?? MasterStateEntity.createNew(); + const orderStat = useMemo(() => { + const state = masterStat.orderStats[masterStat.orderStats.length - 1]; + return state.type; + }, [masterStat]); + + const changeOrderStat = useCallback( + (status: OrderStatType) => { + submit({ status }, { method: "POST" }); + }, + [submit], + ); const { data: orders } = useSWRSubscription( "orders", @@ -54,6 +80,20 @@ export default function FielsOfMaster() {

マスター

+

提供待ちオーダー数:{unserved}

@@ -79,8 +119,8 @@ export default function FielsOfMaster() {
- {order.items.map((item) => ( -
+ {order.items.map((item, index) => ( +
{ +// TODO: ファイル分割してリファクタリングする +export const clientAction: ClientActionFunction = async (args) => { + const method = args.request.method; + switch (method) { + case "PUT": + return addComment(args); + case "POST": + return changeOrderStat(args); + default: + throw new Error(`Method ${method} is not allowed`); + } +}; + +export const addComment: ClientActionFunction = async ({ request }) => { const formData = await request.formData(); const schema = z.object({ @@ -155,3 +208,31 @@ export const clientAction: ClientActionFunction = async ({ request }) => { return new Response("ok"); }; + +export const changeOrderStat: ClientActionFunction = async ({ request }) => { + const formData = await request.formData(); + + const schema = z.object({ + status: z.enum(orderStatTypes), + }); + const submission = parseWithZod(formData, { + schema, + }); + if (submission.status !== "success") { + console.error(submission.error); + return submission.reply(); + } + + const { status } = submission.value; + + const masterStats: MasterStateEntity = + (await masterRepository.get()) ?? MasterStateEntity.createNew(); + + console.log(status); + masterStats.addOrderStat(status); + console.log(masterStats); + + await masterRepository.set(masterStats); + + return new Response("ok"); +}; diff --git a/pos/app/routes/cashier.tsx b/pos/app/routes/cashier.tsx index 41da136e..2d30f189 100644 --- a/pos/app/routes/cashier.tsx +++ b/pos/app/routes/cashier.tsx @@ -7,6 +7,7 @@ import { import { itemSource } from "common/data/items"; import { stringToJSONSchema } from "common/lib/custom-zod"; import { OrderEntity, orderSchema } from "common/models/order"; +import { cashierRepository } from "common/repositories/global"; import { orderRepository } from "common/repositories/order"; import { useCallback } from "react"; import { z } from "zod"; @@ -39,13 +40,37 @@ export default function Cashier() { [submit], ); + const syncOrder = useCallback( + (order: OrderEntity) => { + submit({ syncOrder: JSON.stringify(order.toOrder()) }, { method: "PUT" }); + }, + [submit], + ); + return ( - + ); } // TODO(toririm): リファクタリングするときにファイルを切り出す -export const clientAction: ClientActionFunction = async ({ request }) => { +export const clientAction: ClientActionFunction = async (args) => { + const method = args.request.method; + switch (method) { + case "POST": + return submitOrderAction(args); + case "PUT": + return syncOrderAction(args); + default: + return new Response("Method not allowed", { status: 405 }); + } +}; + +export const submitOrderAction: ClientActionFunction = async ({ request }) => { const formData = await request.formData(); const schema = z.object({ @@ -68,3 +93,27 @@ export const clientAction: ClientActionFunction = async ({ request }) => { return new Response("ok"); }; + +export const syncOrderAction: ClientActionFunction = async ({ request }) => { + const formData = await request.formData(); + + const schema = z.object({ + syncOrder: stringToJSONSchema.pipe(orderSchema), + }); + const submission = parseWithZod(formData, { + schema, + }); + if (submission.status !== "success") { + console.error(submission.error); + return submission.reply(); + } + + const { syncOrder } = submission.value; + + cashierRepository.set({ + id: "cashier-state", + edittingOrder: OrderEntity.fromOrder(syncOrder), + }); + + return new Response("ok"); +};