Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

購入履歴画面を追加 #108

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
11 changes: 10 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
faGear,
faHistory,
faHouse,
faUserEdit,
IconDefinition,
Expand All @@ -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);

Expand All @@ -34,6 +36,13 @@ function App() {
iconPosition: "left",
contents: <Home />,
},
{
item: "history",
displayText: "購入履歴",
icon: faHistory,
iconPosition: "left",
contents: <PurchaseHistory />,
},
{
item: "memberSettings",
displayText: "利用者設定",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/TwoColumnLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ReactNode } from "react";

type Props = {
children: ReactNode;
h: string;
h?: string;
};

export function TwoColumnLayout({ children, h = "100%" }: Props) {
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/contexts/MembersContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
117 changes: 117 additions & 0 deletions frontend/src/features/purchase-history/components/HistoryCard.tsx
Original file line number Diff line number Diff line change
@@ -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<string>("");
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 (
<>
<Flex
_first={{ borderTop: "1px", borderColor: "blackAlpha.200" }}
borderBottom="1px"
borderColor="blackAlpha.200"
justify="space-between"
alignItems="center"
px={4}
py={2}
>
<AspectRatio
ratio={1 / 1}
w={16}
border="1px"
borderColor="blackAlpha.300"
rounded={8}
>
{history.itemId === null ? (
<Box />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Boxはどういうときに表示されますか?
historyのitemIdってnullもあり得るんでしたっけ(覚えてない)

) : (
<Image src={imageURL} objectFit="cover" />
)}
</AspectRatio>
<Box ml={4}>
<HStack spacing={2} align="center">
<Text fontSize="xl" fontWeight="bold">
{history.itemName}
</Text>
</HStack>
<Text textColor="gray">
{DateFormatter.convertDateFormat(history.date) +
", " +
history.price +
" 円"}
</Text>
</Box>
<Spacer />
<IconButton
variant="unstyled"
aria-label="購入履歴を削除"
onClick={onOpen}
icon={<FontAwesomeIcon icon={faTrash} color="red" />}
/>
</Flex>
<HistoryDeleteConfirm
isOpen={isOpen}
onClose={onClose}
historyId={history.historyId}
onClickDeleteButton={() => {
deleteHistory(history);
onClose();
}}
/>
</>
);
}

export { HistoryCard };
Original file line number Diff line number Diff line change
@@ -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 (
<Modal isOpen={isOpen} onClose={onClose} isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>購入履歴の削除</ModalHeader>
<ModalCloseButton />
<ModalBody>
<p>購入履歴を削除しますか?</p>
</ModalBody>
<ModalFooter>
<Button mr={3} onClick={onClose}>
キャンセル
</Button>
<Button colorScheme="red" onClick={onClickDeleteButton}>
削除
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}

export { HistoryDeleteConfirm };
45 changes: 45 additions & 0 deletions frontend/src/features/purchase-history/components/HistoryList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Center h="100%">
<Heading size="md" textColor="gray">
利用者を選択してください
</Heading>
</Center>
);
}

if (histories.length === 0) {
return (
<Center>
<Text size="md" textColor="gray">
購入履歴がありません
</Text>
</Center>
);
}

return (
<>
{histories.map((history) => (
<HistoryCard
key={history.historyId}
history={history}
onDeleteHistory={onDeleteHistory}
/>
))}
</>
);
}

export { HistoryList };
86 changes: 86 additions & 0 deletions frontend/src/features/purchase-history/index.tsx
Original file line number Diff line number Diff line change
@@ -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<string>("");
const [histories, setHistories] = useState<History[]>([]);
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 (
<TwoColumnLayout>
<LeftColumn>
<MemberPane
selectedMemberId={selectedMemberId}
onClickMemberCard={async (memberId) => {
// 購入履歴の取得が完了するまでmemberIdをセットしない
await fetchHistories(memberId);
setSelectedMemberId(memberId);
}}
/>
</LeftColumn>
<RightColumn>
<Heading size="md" mb={4}>
購入履歴
</Heading>
<HistoryList
histories={histories}
isMemberSelected={selectedMemberId !== ""}
onDeleteHistory={deleteHistory}
/>
</RightColumn>
</TwoColumnLayout>
);
}

export { PurchaseHistory };