diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8eef912..52caf6e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,6 @@ import { faGear, + faHistory, faHouse, faUserEdit, IconDefinition, @@ -11,7 +12,8 @@ import { ItemsProvider } from "contexts/ItemsContext"; import { Header } from "components/Header"; import { UserSetting } from "Setting/User/UserSetting"; import { AdminPane } from "Setting/Admin/AdminPane"; -import { Home } from "features/home/Home"; +import { Home } from "features/home"; +import { PurchaseHistory } from "features/purchase-history"; export const TabIndex = createContext(0); @@ -34,6 +36,13 @@ function App() { iconPosition: "left", contents: , }, + { + item: "history", + displayText: "購入履歴", + icon: faHistory, + iconPosition: "left", + contents: , + }, { item: "memberSettings", displayText: "利用者設定", diff --git a/frontend/src/features/home/components/MemberPane.tsx b/frontend/src/components/MemberPane.tsx similarity index 95% rename from frontend/src/features/home/components/MemberPane.tsx rename to frontend/src/components/MemberPane.tsx index a326722..42b2bfe 100644 --- a/frontend/src/features/home/components/MemberPane.tsx +++ b/frontend/src/components/MemberPane.tsx @@ -1,6 +1,6 @@ import { Heading, Stack } from "@chakra-ui/react"; import { useMembers } from "contexts/MembersContext"; -import { MemberCard } from "./MemberCard"; +import { MemberCard } from "features/home/components/MemberCard"; type Props = { selectedMemberId: string; diff --git a/frontend/src/components/TwoColumnLayout.tsx b/frontend/src/components/TwoColumnLayout.tsx index 78ba4b5..bbd27a8 100644 --- a/frontend/src/components/TwoColumnLayout.tsx +++ b/frontend/src/components/TwoColumnLayout.tsx @@ -3,7 +3,7 @@ import { ReactNode } from "react"; type Props = { children: ReactNode; - h: string; + h?: string; }; export function TwoColumnLayout({ children, h = "100%" }: Props) { diff --git a/frontend/src/contexts/MembersContext.tsx b/frontend/src/contexts/MembersContext.tsx index 9610ef8..d2adf99 100644 --- a/frontend/src/contexts/MembersContext.tsx +++ b/frontend/src/contexts/MembersContext.tsx @@ -59,7 +59,8 @@ type Action = | { type: "added"; id: string; name: string; attribute: string } | { type: "deleted"; id: string } | { type: "switchedActivity"; id: string; active: boolean } - | { type: "purchased"; id: string; price: number }; + | { type: "purchased"; id: string; price: number } + | { type: "purchaseCanceled"; id: string; price: number }; function membersReducer(members: Member[], action: Action) { switch (action.type) { @@ -98,6 +99,16 @@ function membersReducer(members: Member[], action: Action) { } return member; }); + case "purchaseCanceled": + return members.map((member) => { + if (member.id === action.id) { + return { + ...member, + payment: Number(member.payment) - Number(action.price), // TODO: 明示的にNumberに変換しないとなぜかNaNになる + }; + } + return member; + }); default: throw new Error("invalid action"); } diff --git a/frontend/src/features/home/Home.tsx b/frontend/src/features/home/index.tsx similarity index 95% rename from frontend/src/features/home/Home.tsx rename to frontend/src/features/home/index.tsx index d93df1a..0592090 100644 --- a/frontend/src/features/home/Home.tsx +++ b/frontend/src/features/home/index.tsx @@ -5,7 +5,7 @@ import { RightColumn, } from "components/TwoColumnLayout"; import { ItemPane } from "./components/ItemPane"; -import { MemberPane } from "./components/MemberPane"; +import { MemberPane } from "components/MemberPane"; import { ConfrimPane } from "./components/ConfirmPane"; function Home() { diff --git a/frontend/src/features/purchase-history/components/HistoryCard.tsx b/frontend/src/features/purchase-history/components/HistoryCard.tsx new file mode 100644 index 0000000..251ab04 --- /dev/null +++ b/frontend/src/features/purchase-history/components/HistoryCard.tsx @@ -0,0 +1,117 @@ +import { + AspectRatio, + Box, + Flex, + HStack, + IconButton, + Image, + Spacer, + Text, + useDisclosure, +} from "@chakra-ui/react"; +import { History } from "types"; +import { DateFormatter } from "util/DateFormatter"; +import { Backend } from "util/Backend"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faTrash } from "@fortawesome/free-solid-svg-icons"; +import { useEffect, useState } from "react"; +import LogoDefaultItem from "image/default_item.svg"; +import { HistoryDeleteConfirm } from "./HistoryDeleteConfirm"; + +type Props = { + history: History; + onDeleteHistory: (history: History) => void; +}; + +function HistoryCard({ history, onDeleteHistory: deleteHistory }: Props) { + // TODO: 画像処理の共通化 + const [imageURL, setImageURL] = useState(""); + const { isOpen, onClose, onOpen } = useDisclosure(); + + useEffect(() => { + let ignore = false; + + async function getImage() { + if (history === null) { + setImageURL(LogoDefaultItem); + return; + } + + const res = await Backend.getItemImage(history.itemId); + if (!ignore) { + if (res === null) { + console.log( + history.itemId + ": Custom image not found. So use default image." + ); + setImageURL(LogoDefaultItem); + return; + } + setImageURL(URL.createObjectURL(res)); + } + } + getImage(); + + return () => { + ignore = true; + }; + }, []); + + return ( + <> + + + {history.itemId === null ? ( + + ) : ( + + )} + + + + + {history.itemName} + + + + {DateFormatter.convertDateFormat(history.date) + + ", " + + history.price + + " 円"} + + + + } + /> + + { + deleteHistory(history); + onClose(); + }} + /> + + ); +} + +export { HistoryCard }; diff --git a/frontend/src/features/purchase-history/components/HistoryDeleteConfirm.tsx b/frontend/src/features/purchase-history/components/HistoryDeleteConfirm.tsx new file mode 100644 index 0000000..090a126 --- /dev/null +++ b/frontend/src/features/purchase-history/components/HistoryDeleteConfirm.tsx @@ -0,0 +1,42 @@ +import { + Button, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalFooter, + ModalHeader, + ModalOverlay, +} from "@chakra-ui/react"; + +type Props = { + isOpen: boolean; + onClose: () => void; + historyId: number; + onClickDeleteButton: () => void; +}; + +function HistoryDeleteConfirm({ isOpen, onClose, onClickDeleteButton }: Props) { + return ( + + + + 購入履歴の削除 + + +

購入履歴を削除しますか?

+
+ + + + +
+
+ ); +} + +export { HistoryDeleteConfirm }; diff --git a/frontend/src/features/purchase-history/components/HistoryList.tsx b/frontend/src/features/purchase-history/components/HistoryList.tsx new file mode 100644 index 0000000..db9730a --- /dev/null +++ b/frontend/src/features/purchase-history/components/HistoryList.tsx @@ -0,0 +1,45 @@ +import { Center, Heading, Text } from "@chakra-ui/react"; +import { History } from "types"; +import { HistoryCard } from "./HistoryCard"; + +type Props = { + histories: History[]; + isMemberSelected: boolean; + onDeleteHistory: (history: History) => void; +}; + +function HistoryList({ histories, isMemberSelected, onDeleteHistory }: Props) { + if (isMemberSelected === false) { + return ( +
+ + 利用者を選択してください + +
+ ); + } + + if (histories.length === 0) { + return ( +
+ + 購入履歴がありません + +
+ ); + } + + return ( + <> + {histories.map((history) => ( + + ))} + + ); +} + +export { HistoryList }; diff --git a/frontend/src/features/purchase-history/index.tsx b/frontend/src/features/purchase-history/index.tsx new file mode 100644 index 0000000..996d610 --- /dev/null +++ b/frontend/src/features/purchase-history/index.tsx @@ -0,0 +1,86 @@ +import { useState } from "react"; +import { Heading, useToast } from "@chakra-ui/react"; +import { + LeftColumn, + RightColumn, + TwoColumnLayout, +} from "components/TwoColumnLayout"; +import { MemberPane } from "components/MemberPane"; +import { HistoryList } from "./components/HistoryList"; +import { Backend } from "util/Backend"; +import { History } from "types"; +import { useMembersDispatch } from "contexts/MembersContext"; + +function PurchaseHistory() { + const [selectedMemberId, setSelectedMemberId] = useState(""); + const [histories, setHistories] = useState([]); + const dispatchMembers = useMembersDispatch(); + const toast = useToast(); + + async function fetchHistories(memberId: string) { + const histories = await Backend.getUserHistory(memberId); + if (histories === null) { + console.error("getUserHistory: failed"); + return; + } + setHistories(histories); + } + + async function deleteHistory(history: History) { + if (!(await Backend.recall(history.historyId))) { + console.error("recall: failed"); + toast({ + title: "購入履歴の削除に失敗しました", + status: "error", + duration: 2000, + isClosable: true, + }); + return; + } + + // memberの利用金額を更新 + dispatchMembers({ + type: "purchaseCanceled", + id: history.memberId, + price: history.price, + }); + + // historyを更新 + setHistories(histories.filter((h) => h.historyId !== history.historyId)); + + // toastを表示 + toast({ + title: "購入履歴を削除しました", + status: "success", + duration: 2000, + isClosable: true, + }); + } + + return ( + + + { + // 購入履歴の取得が完了するまでmemberIdをセットしない + await fetchHistories(memberId); + setSelectedMemberId(memberId); + }} + /> + + + + 購入履歴 + + + + + ); +} + +export { PurchaseHistory };