From f5fd456e0d15b4e43cabf79399da71df5fe04a26 Mon Sep 17 00:00:00 2001 From: "sosuke.iwabuchi" Date: Tue, 22 Aug 2023 11:26:19 +0900 Subject: [PATCH] =?UTF-8?q?=E9=A0=98=E5=8F=8E=E8=A8=BC=E7=99=BA=E8=A1=8C?= =?UTF-8?q?=E4=BE=9D=E9=A0=BC=E7=94=B3=E8=AB=8B=E3=80=80=E3=83=A2=E3=83=83?= =?UTF-8?q?=E3=82=AF=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/codes/page.ts | 2 + src/components/table/index.tsx | 14 +- src/contexts/ReceiptRequestContext.tsx | 157 +++++++++++ src/hooks/useNavigateCustom.ts | 3 + src/hooks/useReceiptRequest.ts | 8 + .../receipt-request/hooks/useAgreement.tsx | 153 +++++++++++ .../receipt-request/hooks/useConfirm.tsx | 114 ++++++++ src/pages/receipt-request/hooks/useInput.tsx | 249 ++++++++++++++++++ src/pages/receipt-request/index.tsx | 54 ++++ src/routes/index.tsx | 2 + src/routes/path.ts | 3 + src/routes/sub/receipt-request.tsx | 23 ++ src/utils/form.ts | 57 ++++ src/utils/number.ts | 8 + src/utils/page.ts | 4 +- 15 files changed, 843 insertions(+), 8 deletions(-) create mode 100644 src/contexts/ReceiptRequestContext.tsx create mode 100644 src/hooks/useReceiptRequest.ts create mode 100644 src/pages/receipt-request/hooks/useAgreement.tsx create mode 100644 src/pages/receipt-request/hooks/useConfirm.tsx create mode 100644 src/pages/receipt-request/hooks/useInput.tsx create mode 100644 src/pages/receipt-request/index.tsx create mode 100644 src/routes/sub/receipt-request.tsx create mode 100644 src/utils/form.ts create mode 100644 src/utils/number.ts diff --git a/src/codes/page.ts b/src/codes/page.ts index f7e2cc3..6a08d37 100644 --- a/src/codes/page.ts +++ b/src/codes/page.ts @@ -30,6 +30,8 @@ export const PageID = { DASHBOARD_LOGIN_USER_CREATE: id++, DASHBOARD_LOGIN_USER_CHANGE_PASSWORD: id++, + RECEIPT_REQUEST: id++, + PAGE_403: id++, PAGE_404: id++, } as const; diff --git a/src/components/table/index.tsx b/src/components/table/index.tsx index e2ca244..c182797 100644 --- a/src/components/table/index.tsx +++ b/src/components/table/index.tsx @@ -14,12 +14,14 @@ import { ReactNode, useEffect, useMemo } from "react"; export { default as TableHeadCustom } from "./TableHeadCustom"; -type SimpleDataListProps = { - data: { - title: string; - value?: string; - end?: ReactNode; - }[]; +export type SimpleData = { + title: string; + value?: string; + end?: ReactNode; +}; + +export type SimpleDataListProps = { + data: SimpleData[]; tableSx?: SxProps; }; export const SimpleDataList = ({ data, tableSx }: SimpleDataListProps) => { diff --git a/src/contexts/ReceiptRequestContext.tsx b/src/contexts/ReceiptRequestContext.tsx new file mode 100644 index 0000000..a62b972 --- /dev/null +++ b/src/contexts/ReceiptRequestContext.tsx @@ -0,0 +1,157 @@ +import { yupResolver } from "@hookform/resolvers/yup"; +import { HasChildren } from "@types"; +import { createContext, useMemo, useState } from "react"; +import { UseFormReturn, useForm } from "react-hook-form"; +import { useParams } from "react-router-dom"; +import * as Yup from "yup"; + +const schema = Yup.object().shape({ + reason: Yup.string().required("入力してください"), + reason_text: Yup.string(), + user_company_name: Yup.string(), + user_first_name: Yup.string().required("入力してください"), + user_last_name: Yup.string().required("入力してください"), + + put_in_date: Yup.date() + .required("必須項目です") + .typeError("正しく入力してください"), + put_in_hour: Yup.string().required("入力してください"), + put_in_minute: Yup.string().required("入力してください"), + put_out_date: Yup.date() + .required("必須項目です") + .typeError("正しく入力してください"), + put_out_hour: Yup.string().required("入力してください"), + put_out_minute: Yup.string().required("入力してください"), + + parking_name: Yup.string().required("入力してください"), + room_no: Yup.string().required("入力してください"), + + receipt_amount: Yup.number() + .typeError("数値を入力してください") + + .test("range", "1-50000まで入力できます", function (value) { + const val = Number(value); + return 1 <= val && val <= 50000; + }), + paying_type: Yup.string().required("入力してください"), + phone_number: Yup.string() + .required("必須項目です") + .matches(/^[0-9]{10,11}$/, "正しい電話番号を入力してください"), + + email: Yup.string() + .required("必須項目です") + .matches(/^.+@.+$/, "正しいEmailを入力してください"), + + memo: Yup.string(), +}); + +export type ReceiptRequestFormProps = { + reason: string; + reason_text: string; + user_company_name: string; + user_first_name: string; + user_last_name: string; + put_in_date: Date | null; + put_in_hour: string; + put_in_minute: string; + put_out_date: Date | null; + put_out_hour: string; + put_out_minute: string; + + parking_name: string; + room_no: string; + + receipt_amount: string; + paying_type: string; + + phone_number: string; + email: string; + + memo: string; +}; + +export function getDefaultReceiptRequestFormValues(): ReceiptRequestFormProps { + return { + reason: "", + reason_text: "", + user_company_name: "", + user_first_name: "", + user_last_name: "", + put_in_date: null, + put_in_hour: "", + put_in_minute: "", + put_out_date: null, + put_out_hour: "", + put_out_minute: "", + + parking_name: "", + room_no: "", + + receipt_amount: "", + paying_type: "", + + phone_number: "", + email: "", + + memo: "", + }; +} + +type Mode = "agree" | "input" | "confirm" | "complete"; + +type ReceiptRequest = { + step: number; + mode: Mode; + inputData: ReceiptRequestFormProps; + form: UseFormReturn | undefined; + setMode: (mode: Mode) => void; + setInputData: (input: ReceiptRequestFormProps) => void; +}; + +export const ReceiptRequestContext = createContext({ + step: 0, + mode: "agree", + inputData: getDefaultReceiptRequestFormValues(), + form: undefined, + setMode: (mode: Mode) => {}, + setInputData: (input: ReceiptRequestFormProps) => {}, +}); + +type Props = HasChildren; +export function ReceiptRequestContextProvider({ children }: Props) { + const { contractId: paramContractId } = useParams(); + + const [mode, setMode] = useState("agree"); + + const [inputData, setInputData] = useState( + getDefaultReceiptRequestFormValues() + ); + + const step = useMemo(() => { + if (mode === "agree") return 0; + if (mode === "input") return 1; + if (mode === "confirm") return 2; + if (mode === "complete") return 3; + throw new Error("ステップ不正"); + }, [mode]); + + const form = useForm({ + defaultValues: getDefaultReceiptRequestFormValues(), + resolver: yupResolver(schema), + }); + + return ( + + {children} + + ); +} diff --git a/src/hooks/useNavigateCustom.ts b/src/hooks/useNavigateCustom.ts index 5fedc96..bf0a4cc 100644 --- a/src/hooks/useNavigateCustom.ts +++ b/src/hooks/useNavigateCustom.ts @@ -2,6 +2,7 @@ import { useLocation, useNavigate } from "react-router"; import { Dictionary } from "@types"; import { PageID } from "codes/page"; import { getPath } from "routes/path"; +import { scrollToTop } from "utils/page"; export default function useNavigateCustom() { const navigate = useNavigate(); @@ -54,6 +55,8 @@ export default function useNavigateCustom() { navigate(newPath); } } + + scrollToTop(); }; return { navigate, navigateWhenChanged }; } diff --git a/src/hooks/useReceiptRequest.ts b/src/hooks/useReceiptRequest.ts new file mode 100644 index 0000000..c606761 --- /dev/null +++ b/src/hooks/useReceiptRequest.ts @@ -0,0 +1,8 @@ +import { ReceiptRequestContext } from "contexts/ReceiptRequestContext"; +import { useContext } from "react"; + +export default function useReceiptRequest() { + const context = useContext(ReceiptRequestContext); + + return context; +} diff --git a/src/pages/receipt-request/hooks/useAgreement.tsx b/src/pages/receipt-request/hooks/useAgreement.tsx new file mode 100644 index 0000000..a794def --- /dev/null +++ b/src/pages/receipt-request/hooks/useAgreement.tsx @@ -0,0 +1,153 @@ +import { yupResolver } from "@hookform/resolvers/yup"; +import { Box, Button, Stack, Typography } from "@mui/material"; +import { PageID } from "codes/page"; +import { FormProvider, RHFCheckbox } from "components/hook-form"; +import useReceiptRequest from "hooks/useReceiptRequest"; +import { useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { getPath } from "routes/path"; +import { scrollToTop } from "utils/page"; +import * as Yup from "yup"; + +type FormProps = { + accept_privacy_policy: boolean; + accept_correct_entry: boolean; + accept_site_policy: boolean; +}; + +const schema = Yup.object().shape({ + accept_privacy_policy: Yup.boolean().test( + "accept", + "同意が必要です", + function (value) { + return !!value; + } + ), + accept_correct_entry: Yup.boolean().test( + "accept", + "同意が必要です", + function (value) { + return !!value; + } + ), + accept_site_policy: Yup.boolean().test( + "accept", + "同意が必要です", + function (value) { + return !!value; + } + ), +}); + +export default function useAgreement() { + const { setMode } = useReceiptRequest(); + + const privacyPolicyUrl: string = useMemo(() => { + return getPath(PageID.APP_PRIVACY_POLICY); + }, []); + + const form = useForm({ + defaultValues: { + accept_privacy_policy: false, + accept_correct_entry: false, + accept_site_policy: false, + }, + resolver: yupResolver(schema), + }); + + const acceptPrivacyPolicy = form.watch("accept_privacy_policy"); + const acceptCorrectEntry = form.watch("accept_correct_entry"); + const acceptSitePolicy = form.watch("accept_site_policy"); + + const canSubmit = useMemo(() => { + return acceptPrivacyPolicy && acceptCorrectEntry && acceptSitePolicy; + }, [acceptPrivacyPolicy, acceptCorrectEntry, acceptSitePolicy]); + + const handleSubmit = () => { + scrollToTop("auto"); + setMode("input"); + }; + + const element = ( + + + + + このページは当社時間貸し駐車場・駐輪場ご利用者の方が領収書の発行(再発行)を申請するためのページです。 + + + ※上記以外(月極駐車場、予約制駐車場等)ではご利用になれませんのでご注意ください。 + + + 領収書の受け取り方法は①WEB発行(メール)②郵送のどちらかになります。 + + + 以下、各項目についてご確認およびご同意頂き、申請フォームへお進みください。 + + + + + <ご申請の流れ> + 1.ご申請(ご利用者様) + + 2.ご申請内容の確認(運営会社) + + + 3.メールにて領収証取得用URL送付(運営会社) + + + + + + + + + + 【 規約 】 + + + + + + + <注意事項> + + ・申請内容に不明な点がある場合、別途お電話等で確認をとらせて頂く場合がございますのでご了承ください。 + + + ・WEB上で領収証PDFをダウンロード可能です。 + + + ・郵送を希望する場合、到着までに時間を要します。お急ぎの場合はダウンロードやEmail送付をご利用ください。 + + + + + 申請内容に虚偽・誤りが無いことへの同意 + + } + name="accept_correct_entry" + /> + + + + + + + + ); + + return { element }; +} diff --git a/src/pages/receipt-request/hooks/useConfirm.tsx b/src/pages/receipt-request/hooks/useConfirm.tsx new file mode 100644 index 0000000..374c3cb --- /dev/null +++ b/src/pages/receipt-request/hooks/useConfirm.tsx @@ -0,0 +1,114 @@ +import { yupResolver } from "@hookform/resolvers/yup"; +import { Box, Button, Stack, Typography } from "@mui/material"; +import { PageID } from "codes/page"; +import { FormProvider, RHFCheckbox } from "components/hook-form"; +import StackRow from "components/stack/StackRow"; +import { SimpleData, SimpleDataList } from "components/table"; +import { intlFormat } from "date-fns"; +import useReceiptRequest from "hooks/useReceiptRequest"; +import { useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { getPath } from "routes/path"; +import { sprintf } from "sprintf-js"; +import { formatDateStr } from "utils/datetime"; +import { numberFormat } from "utils/number"; +import { scrollToTop } from "utils/page"; +import * as Yup from "yup"; + +export default function useConfirm() { + const { setMode, inputData } = useReceiptRequest(); + + const data: SimpleData[] = useMemo(() => { + return [ + { + title: "申請理由", + value: inputData.reason, + }, + { + title: "申請者名(会社名)", + value: inputData.user_company_name, + }, + { + title: "申請者名", + value: sprintf( + "%s %s", + inputData.user_first_name, + inputData.user_last_name + ), + }, + { + title: "入庫時刻", + value: sprintf( + "%s %s:%s", + formatDateStr(inputData.put_in_date), + inputData.put_in_hour, + inputData.put_in_minute + ), + }, + { + title: "精算時刻", + value: sprintf( + "%s %s:%s", + formatDateStr(inputData.put_out_date), + inputData.put_out_hour, + inputData.put_out_minute + ), + }, + { + title: "駐車場名", + value: inputData.parking_name, + }, + { + title: "車室番号", + value: inputData.room_no, + }, + { + title: "ご利用金額", + value: sprintf("%s円", numberFormat(inputData.receipt_amount)), + }, + { + title: "支払方法", + value: inputData.paying_type, + }, + { + title: "ご連絡先電話番号", + value: inputData.phone_number, + }, + { + title: "ご連絡先Email", + value: inputData.email, + }, + { + title: "その他伝達事項", + value: inputData.memo, + }, + ]; + }, [inputData]); + + const handlePrev = () => { + scrollToTop("auto"); + setMode("input"); + }; + + const element = ( + + + + 下記の内容で申請を行います。 + 内容にお間違いがないか確認したうえ、確定を行ってください。 + + + + + + + + + ); + + return { element }; +} diff --git a/src/pages/receipt-request/hooks/useInput.tsx b/src/pages/receipt-request/hooks/useInput.tsx new file mode 100644 index 0000000..4d1948d --- /dev/null +++ b/src/pages/receipt-request/hooks/useInput.tsx @@ -0,0 +1,249 @@ +import { yupResolver } from "@hookform/resolvers/yup"; +import { + Box, + Button, + Divider, + Link, + Stack, + Typography, + TypographyProps, +} from "@mui/material"; +import { HasChildren } from "@types"; +import { PageID } from "codes/page"; +import RequireChip from "components/chip/RequireChip"; +import { + FormProvider, + RHFCheckbox, + RHFSelect, + RHFTextField, +} from "components/hook-form"; +import RHFDatePicker from "components/hook-form/RHFDatePicker"; +import { SelectOptionProps } from "components/hook-form/RHFSelect"; +import RHFPrefCodeSelect from "components/hook-form/ex/RHFPrefCodeSelect"; +import StackRow from "components/stack/StackRow"; +import { + ReceiptRequestFormProps, + getDefaultReceiptRequestFormValues, +} from "contexts/ReceiptRequestContext"; +import useReceiptRequest from "hooks/useReceiptRequest"; +import { range } from "lodash"; +import { useMemo } from "react"; +import { useForm } from "react-hook-form"; +import { getPath } from "routes/path"; +import { sprintf } from "sprintf-js"; +import { scrollToTop } from "utils/page"; +import * as Yup from "yup"; + +type AreaBoxProps = { + title: string; + require?: boolean; +} & HasChildren; +function AreaBox({ title, children, require }: AreaBoxProps) { + return ( + + + 〇 {title} + + + {children} + + ); +} + +function Explain({ children, ...props }: TypographyProps) { + return ( + + {children} + + ); +} + +export default function useInput() { + const { setMode, setInputData, form } = useReceiptRequest(); + + const hours: SelectOptionProps[] = useMemo(() => { + return [ + { + label: "--", + value: "", + }, + ...range(0, 24).map((val) => ({ + label: sprintf("%02d", val), + value: String(val), + })), + ]; + }, []); + const minutes: SelectOptionProps[] = useMemo(() => { + return [ + { + label: "--", + value: "", + }, + ...range(0, 60).map((val) => ({ + label: sprintf("%02d", val), + value: String(val), + })), + ]; + }, []); + const reasons: SelectOptionProps[] = useMemo(() => { + return [ + { + label: "--", + value: "", + }, + { + label: "取り忘れ", + value: "取り忘れ", + }, + { + label: "紛失", + value: "紛失", + }, + { + label: "その他", + value: "その他", + }, + ]; + }, []); + const payingTypes: SelectOptionProps[] = useMemo(() => { + return [ + { + label: "--", + value: "", + }, + { + label: "現金", + value: "現金", + }, + { + label: "クレジットカード", + value: "クレジットカード", + }, + { + label: "電子マネー", + value: "電子マネー", + }, + ]; + }, []); + + const handleSubmit = () => { + if (!form) return; + setInputData(form.getValues()); + scrollToTop("auto"); + setMode("confirm"); + }; + + const handlePrev = () => { + setMode("agree"); + }; + + const element = !!form && ( + + + + + +   + ※その他の場合に入力してください + + + + + ※法人の方の場合のみ入力してください + + + + + + + + + + + + + + + ※不明の場合はおおよそで構いません + + + + + + + + + + + + ※不明の場合はおおよそで構いません + + + + + + + + + + + + 駐車場名はこちらで検索することができます + + + ユアー・パーキング駐車場検索 + + + + + + + ※分からない場合は「不明」と入力してください + + + + + + + + + + ※ハイフン不要 + + + + + + + + + + + + + + + + ); + + return { element }; +} diff --git a/src/pages/receipt-request/index.tsx b/src/pages/receipt-request/index.tsx new file mode 100644 index 0000000..b8150f7 --- /dev/null +++ b/src/pages/receipt-request/index.tsx @@ -0,0 +1,54 @@ +import { + Box, + Paper, + Step, + StepLabel, + Stepper, + Typography, +} from "@mui/material"; +import { ReceiptRequestContextProvider } from "contexts/ReceiptRequestContext"; +import useAgreement from "./hooks/useAgreement"; +import useReceiptRequest from "hooks/useReceiptRequest"; +import useInput from "./hooks/useInput"; +import useConfirm from "./hooks/useConfirm"; + +export default function ReceiptRequest() { + return ( + + + + ); +} +function App() { + const { mode, step } = useReceiptRequest(); + const agreement = useAgreement(); + const input = useInput(); + const confirm = useConfirm(); + + return ( + + + 領収証発行(再発行)申請 + + + + + 個人情報保護指針への同意 + + + ご申請内容の入力 + + + ご申請内容の確認 + + + ご申請完了 + + + + {mode === "agree" && agreement.element} + {mode === "input" && input.element} + {mode === "confirm" && confirm.element} + + ); +} diff --git a/src/routes/index.tsx b/src/routes/index.tsx index bf7136a..bee3615 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -6,6 +6,7 @@ import AppRoutes from "./sub/app"; import AuthRoutes from "./sub/auth"; import CommonRoutes from "./sub/common"; import DashboardRoutes from "./sub/dashboard"; +import RequestReceiptRoutes from "./sub/receipt-request"; export const Loadable = (Component: ElementType) => (props: any) => { return ( @@ -22,6 +23,7 @@ export function Routes() { AuthRoutes(), AppRoutes(), DashboardRoutes(), + RequestReceiptRoutes(), { path: "*", element: initialized ? : , diff --git a/src/routes/path.ts b/src/routes/path.ts index 38ce0cf..f6d193b 100644 --- a/src/routes/path.ts +++ b/src/routes/path.ts @@ -83,6 +83,9 @@ const PATHS = { [makePathKey(PageID.DASHBOARD_LOGIN_USER_CHANGE_PASSWORD)]: "/dashboard/login-user/change-password/:id", + // 領収証リクエスト + [makePathKey(PageID.RECEIPT_REQUEST)]: "/request/receipt/:contractId", + // その他 [makePathKey(PageID.PAGE_403)]: "403", [makePathKey(PageID.PAGE_404)]: "404", diff --git a/src/routes/sub/receipt-request.tsx b/src/routes/sub/receipt-request.tsx new file mode 100644 index 0000000..1c6894d --- /dev/null +++ b/src/routes/sub/receipt-request.tsx @@ -0,0 +1,23 @@ +import { PageID } from "codes/page"; +import SimpleLayout from "layouts/simple"; +import { lazy, useMemo } from "react"; +import { RouteObject } from "react-router-dom"; +import { Loadable } from "routes"; +import { getRoute } from "routes/path"; + +export default function ReceiptRequestRoutes(): RouteObject { + const ReceiptRequest = Loadable(lazy(() => import("pages/receipt-request"))); + const children: RouteObject[] = useMemo(() => { + return [ + { + path: getRoute(PageID.RECEIPT_REQUEST), + element: , + }, + ]; + }, []); + + return { + element: , + children, + }; +} diff --git a/src/utils/form.ts b/src/utils/form.ts new file mode 100644 index 0000000..ea84e62 --- /dev/null +++ b/src/utils/form.ts @@ -0,0 +1,57 @@ +import { sprintf } from "sprintf-js"; +import * as Yup from "yup"; + +type Require = { + require?: boolean; +}; + +type Range = { + min?: number; + max?: number; +}; + +export const Rule = { + DEFAULT_STR_MAX_LENGTH: 200, + DEFAULT_NUMBER_MAX_LENGTH: 50000, + + string: function (param: Require & Range) { + const ret = Yup.string(); + if (param.require) { + ret.required("入力してください"); + } + + // 最大値最小値設定 + const min = getMin(param); + const max = getMax(param, this.DEFAULT_STR_MAX_LENGTH); + const message = sprintf("%d-%d文字入力できます", min, max); + ret.test("range", message, function (value) { + const len = value ? value.length : 0; + return min <= len && len <= max; + }); + + ret.typeError("入力を確認してください"); + return ret; + }, + + number: function (param: Require & Range) { + const ret = Yup.number(); + if (param.require) { + ret.required("入力してください"); + } + }, +}; + +function getMin(param: Range & Require): number { + if (param.min !== undefined) return param.min; + + if (param.require) { + return 1; + } else { + return 0; + } +} +function getMax(param: Range, defaultValue: number): number { + if (param.max !== undefined) return param.max; + + return 200; +} diff --git a/src/utils/number.ts b/src/utils/number.ts new file mode 100644 index 0000000..00df50c --- /dev/null +++ b/src/utils/number.ts @@ -0,0 +1,8 @@ +const NumberFormat = new Intl.NumberFormat(); + +export function numberFormat(value: string | number): string { + if (typeof value === "number") { + return NumberFormat.format(value); + } + return NumberFormat.format(Number(value)); +} diff --git a/src/utils/page.ts b/src/utils/page.ts index f9c9976..73ae158 100644 --- a/src/utils/page.ts +++ b/src/utils/page.ts @@ -1,3 +1,3 @@ -export const scrollToTop = () => { - window.scroll({ top: 0, behavior: "smooth" }); +export const scrollToTop = (behavior: ScrollBehavior = "smooth") => { + window.scroll({ top: 0, behavior }); };