| @@ -0,0 +1,20 @@ | |||
| # .env.development.localと.env.production.localを用意すること | |||
| # REACT_APP_ENV=local | |||
| # REACT_APP_ENV=staging | |||
| # REACT_APP_ENV=production | |||
| GENERATE_SOURCEMAP=true | |||
| PORT=8080 | |||
| # HOST | |||
| # ローカル---------- | |||
| # REACT_APP_HOST_API_KEY=http://localhost | |||
| # ステージング------------- | |||
| REACT_APP_HOST_API_KEY= | |||
| @@ -0,0 +1,23 @@ | |||
| # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | |||
| # dependencies | |||
| /node_modules | |||
| /.pnp | |||
| .pnp.js | |||
| # testing | |||
| /coverage | |||
| # production | |||
| /build | |||
| # misc | |||
| .DS_Store | |||
| .env.local | |||
| .env.development.local | |||
| .env.test.local | |||
| .env.production.local | |||
| npm-debug.log* | |||
| yarn-debug.log* | |||
| yarn-error.log* | |||
| @@ -0,0 +1,46 @@ | |||
| # Getting Started with Create React App | |||
| This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). | |||
| ## Available Scripts | |||
| In the project directory, you can run: | |||
| ### `yarn start` | |||
| Runs the app in the development mode.\ | |||
| Open [http://localhost:3000](http://localhost:3000) to view it in the browser. | |||
| The page will reload if you make edits.\ | |||
| You will also see any lint errors in the console. | |||
| ### `yarn test` | |||
| Launches the test runner in the interactive watch mode.\ | |||
| See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. | |||
| ### `yarn build` | |||
| Builds the app for production to the `build` folder.\ | |||
| It correctly bundles React in production mode and optimizes the build for the best performance. | |||
| The build is minified and the filenames include the hashes.\ | |||
| Your app is ready to be deployed! | |||
| See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. | |||
| ### `yarn eject` | |||
| **Note: this is a one-way operation. Once you `eject`, you can’t go back!** | |||
| If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. | |||
| Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. | |||
| You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. | |||
| ## Learn More | |||
| You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). | |||
| To learn React, check out the [React documentation](https://reactjs.org/). | |||
| @@ -0,0 +1,16 @@ | |||
| version: '3' | |||
| services: | |||
| nodejs: | |||
| build: | |||
| context: ./docker/node | |||
| volumes: | |||
| - .:/app | |||
| # 常時起動用のオプション | |||
| tty: true | |||
| stdin_open: true | |||
| ports: | |||
| - 3000:3000 | |||
| @@ -0,0 +1,3 @@ | |||
| FROM node:20-alpine | |||
| WORKDIR /app | |||
| @@ -0,0 +1,65 @@ | |||
| { | |||
| "name": "kt-my-page", | |||
| "version": "0.1.0", | |||
| "private": true, | |||
| "dependencies": { | |||
| "@emotion/react": "^11.10.8", | |||
| "@emotion/styled": "^11.10.8", | |||
| "@hookform/resolvers": "^3.1.0", | |||
| "@mui/icons-material": "^5.11.16", | |||
| "@mui/lab": "^5.0.0-alpha.129", | |||
| "@mui/material": "^5.12.2", | |||
| "@mui/x-date-pickers": "^6.4.0", | |||
| "@testing-library/jest-dom": "^5.14.1", | |||
| "@testing-library/react": "^13.0.0", | |||
| "@testing-library/user-event": "^13.2.1", | |||
| "@types/axios": "^0.14.0", | |||
| "@types/date-fns": "^2.6.0", | |||
| "@types/jest": "^27.0.1", | |||
| "@types/lodash": "^4.14.194", | |||
| "@types/node": "^16.7.13", | |||
| "@types/qrcode": "^1.5.5", | |||
| "@types/react": "^18.0.0", | |||
| "@types/react-dom": "^18.0.0", | |||
| "@types/react-router-dom": "^5.3.3", | |||
| "@types/sprintf-js": "^1.1.2", | |||
| "axios": "^1.4.0", | |||
| "date-fns": "^2.30.0", | |||
| "lodash": "^4.17.21", | |||
| "notistack": "^3.0.1", | |||
| "qrcode": "^1.5.3", | |||
| "react": "^18.2.0", | |||
| "react-dom": "^18.2.0", | |||
| "react-hook-form": "^7.43.9", | |||
| "react-router-dom": "^6.11.0", | |||
| "react-scripts": "5.0.1", | |||
| "sprintf-js": "^1.1.2", | |||
| "typescript": "^4.4.2", | |||
| "web-vitals": "^2.1.0", | |||
| "yup": "^1.1.1" | |||
| }, | |||
| "scripts": { | |||
| "start": "react-scripts start", | |||
| "build": "react-scripts build", | |||
| "test": "react-scripts test", | |||
| "eject": "react-scripts eject" | |||
| }, | |||
| "eslintConfig": { | |||
| "extends": [ | |||
| "react-app", | |||
| "react-app/jest" | |||
| ] | |||
| }, | |||
| "browserslist": { | |||
| "production": [ | |||
| ">0.2%", | |||
| "not dead", | |||
| "not op_mini all" | |||
| ], | |||
| "development": [ | |||
| "last 1 chrome version", | |||
| "last 1 firefox version", | |||
| "last 1 safari version" | |||
| ] | |||
| } | |||
| } | |||
| @@ -0,0 +1,59 @@ | |||
| <IfModule mod_rewrite.c> | |||
| <IfModule mod_negotiation.c> | |||
| Options -MultiViews -Indexes | |||
| </IfModule> | |||
| RewriteEngine On | |||
| # 環境判定判定 | |||
| SetEnvIf HOST "^.*easyreceipt.jp$" isProduction=yes | |||
| SetEnvIf HOST "15.152.238.14" isStaging=yes | |||
| SetEnvIf HOST "^localhost.*$" isLocal=yes | |||
| # SSL強制 | |||
| RewriteCond %{ENV:isProduction} yes | |||
| RewriteCond %{HTTPS} off | |||
| RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L] | |||
| # SSL強制のキャッシュ | |||
| Header set "Strict-Transport-Security" "max-age=86400" env=isProduction | |||
| Header unset X-Powered-By | |||
| Header set "Content-Security-Policy" "default-src 'self';style-src 'self' 'unsafe-inline' fonts.googleapis.com;img-src 'self' data: api.iconify.design;font-src 'self' fonts.gstatic.com;frame-ancestors 'none';form-action 'self';connect-src 'self' https:" env=isProduction | |||
| Header set "Content-Security-Policy" "default-src 'self';style-src 'self' 'unsafe-inline' fonts.googleapis.com;img-src 'self' data: api.iconify.design;font-src 'self' fonts.gstatic.com;frame-ancestors 'none';form-action 'self';connect-src 'self' https:" env=isStaging | |||
| Header set "X-Frame-Options" "deny" env=isProduction | |||
| Header set "X-Frame-Options" "deny" env=isStaging | |||
| Header set "Cache-Control" "no-cache, no-store, must-revalidate" | |||
| # Handle Authorization Header | |||
| RewriteCond %{HTTP:Authorization} . | |||
| RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] | |||
| # Redirect Trailing Slashes If Not A Folder... | |||
| RewriteCond %{REQUEST_FILENAME} !-d | |||
| RewriteCond %{REQUEST_URI} (.+)/$ | |||
| RewriteRule ^ %1 [L,R=301] | |||
| # Send Requests To Front Controller... | |||
| RewriteCond %{REQUEST_FILENAME} !-d | |||
| RewriteCond %{REQUEST_FILENAME} !-f | |||
| RewriteRule ^ index.php [L] | |||
| # 以下、ファイルアクセス | |||
| # リソース判定 | |||
| SetEnvIf Request_URI "^\/static\/js\/.+\.js$" isJsFile=yes | |||
| SetEnvIf Request_URI "^\/static\/css\/.+\.css$" isCssFile=yes | |||
| SetEnvIf Request_URI "^\/fonts\/.+$" isFontFile=yes | |||
| Header set "Cache-Control" "private, no-cache" env=isJsFile | |||
| Header set "Cache-Control" "private, no-cache" env=isCssFile | |||
| Header set "Cache-Control" "private, no-cache" env=isFontFile | |||
| Header set "X-Content-Type-Options" "nosniff" env=isProduction | |||
| Header set "X-Content-Type-Options" "nosniff" env=isStaging | |||
| </IfModule> | |||
| @@ -0,0 +1,22 @@ | |||
| <!DOCTYPE html> | |||
| <html lang="ja"> | |||
| <head> | |||
| <meta charset="utf-8" /> | |||
| <link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> | |||
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |||
| <meta name="theme-color" content="#000000" /> | |||
| <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> | |||
| <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> | |||
| <title>QRサービス券</title> | |||
| </head> | |||
| <body> | |||
| <noscript>You need to enable JavaScript to run this app.</noscript> | |||
| <div id="root"></div> | |||
| </body> | |||
| </html> | |||
| @@ -0,0 +1,25 @@ | |||
| { | |||
| "short_name": "EasyReceipt", | |||
| "name": "EasyReceipt", | |||
| "icons": [ | |||
| { | |||
| "src": "favicon.ico", | |||
| "sizes": "64x64 32x32 24x24 16x16", | |||
| "type": "image/x-icon" | |||
| }, | |||
| { | |||
| "src": "logo192.png", | |||
| "type": "image/png", | |||
| "sizes": "192x192" | |||
| }, | |||
| { | |||
| "src": "logo512.png", | |||
| "type": "image/png", | |||
| "sizes": "512x512" | |||
| } | |||
| ], | |||
| "start_url": ".", | |||
| "display": "standalone", | |||
| "theme_color": "#000000", | |||
| "background_color": "#ffffff" | |||
| } | |||
| @@ -0,0 +1,3 @@ | |||
| # https://www.robotstxt.org/robotstxt.html | |||
| User-agent: * | |||
| Disallow: | |||
| @@ -0,0 +1,11 @@ | |||
| import { ReactNode } from "react"; | |||
| export type DataUrl = string; | |||
| export type HasChildren = { | |||
| children: ReactNode; | |||
| }; | |||
| export type Dictionary = { | |||
| [key: string]: string; | |||
| }; | |||
| @@ -0,0 +1,38 @@ | |||
| .App { | |||
| text-align: center; | |||
| } | |||
| .App-logo { | |||
| height: 40vmin; | |||
| pointer-events: none; | |||
| } | |||
| @media (prefers-reduced-motion: no-preference) { | |||
| .App-logo { | |||
| animation: App-logo-spin infinite 20s linear; | |||
| } | |||
| } | |||
| .App-header { | |||
| background-color: #282c34; | |||
| min-height: 100vh; | |||
| display: flex; | |||
| flex-direction: column; | |||
| align-items: center; | |||
| justify-content: center; | |||
| font-size: calc(10px + 2vmin); | |||
| color: white; | |||
| } | |||
| .App-link { | |||
| color: #61dafb; | |||
| } | |||
| @keyframes App-logo-spin { | |||
| from { | |||
| transform: rotate(0deg); | |||
| } | |||
| to { | |||
| transform: rotate(360deg); | |||
| } | |||
| } | |||
| @@ -0,0 +1,9 @@ | |||
| import React from 'react'; | |||
| import { render, screen } from '@testing-library/react'; | |||
| import App from './App'; | |||
| test('renders learn react link', () => { | |||
| render(<App />); | |||
| const linkElement = screen.getByText(/learn react/i); | |||
| expect(linkElement).toBeInTheDocument(); | |||
| }); | |||
| @@ -0,0 +1,38 @@ | |||
| import { CssBaseline } from "@mui/material"; | |||
| import AuthContextProvider from "contexts/AuthContext"; | |||
| import { PageContextProvider } from "contexts/PageContext"; | |||
| import { WindowSizeContextProvider } from "contexts/WindowSizeContext"; | |||
| import CsrfTokenProvider from "providers/CsrfTokenProvider"; | |||
| import SnackbarProvider from "providers/SnackbarProvider"; | |||
| import { BrowserRouter } from "react-router-dom"; | |||
| import { Routes } from "routes"; | |||
| import AppThemeProvider from "theme"; | |||
| import { LocalizationProvider } from "@mui/x-date-pickers"; | |||
| import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns"; | |||
| import ja from "date-fns/locale/ja"; | |||
| import BackDropContextProvider from "contexts/BackDropContext"; | |||
| export default function App() { | |||
| return ( | |||
| <LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={ja}> | |||
| <AuthContextProvider> | |||
| <PageContextProvider> | |||
| <WindowSizeContextProvider> | |||
| <BrowserRouter> | |||
| <AppThemeProvider> | |||
| <SnackbarProvider> | |||
| <BackDropContextProvider> | |||
| <CsrfTokenProvider /> | |||
| <CssBaseline /> | |||
| <Routes /> | |||
| </BackDropContextProvider> | |||
| </SnackbarProvider> | |||
| </AppThemeProvider> | |||
| </BrowserRouter> | |||
| </WindowSizeContextProvider> | |||
| </PageContextProvider> | |||
| </AuthContextProvider> | |||
| </LocalizationProvider> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,47 @@ | |||
| import { UserRole } from "auth/UserRole"; | |||
| import { APICommonResponse, ApiId, HttpMethod, makeParam, request } from "."; | |||
| import { getUrl } from "./url"; | |||
| export type Me = { | |||
| id: string; | |||
| name: string; | |||
| email: string; | |||
| role: UserRole; | |||
| shop_id: string | null; | |||
| }; | |||
| type MeResponse = { | |||
| data: Me; | |||
| } & APICommonResponse; | |||
| export const csrfToken = async () => { | |||
| await request({ | |||
| url: getUrl(ApiId.CSRF_TOKEN), | |||
| method: HttpMethod.GET, | |||
| }); | |||
| }; | |||
| export const me = async () => { | |||
| const res = await request<MeResponse>({ | |||
| url: getUrl(ApiId.ME), | |||
| method: HttpMethod.GET, | |||
| }); | |||
| return res; | |||
| }; | |||
| export const login = async (param: { email: string; password: string }) => { | |||
| const res = await request({ | |||
| url: getUrl(ApiId.LOGIN), | |||
| method: HttpMethod.POST, | |||
| data: param, | |||
| }); | |||
| return res; | |||
| }; | |||
| export const logout = async () => { | |||
| const res = await request({ | |||
| url: getUrl(ApiId.LOGOUT), | |||
| method: HttpMethod.GET, | |||
| }); | |||
| return res; | |||
| }; | |||
| @@ -0,0 +1,24 @@ | |||
| import { APICommonResponse, ApiId, HttpMethod, makeParam, request } from "api"; | |||
| import { getUrl } from "./url"; | |||
| type 顧客マスタ = { | |||
| id: number; | |||
| customer_id: string; | |||
| customer_name: string; | |||
| }; | |||
| // -------顧客ログインユーザ新規登録--------------- | |||
| export type 顧客マスタ一覧取得Request = {}; | |||
| export type 顧客マスタ一覧取得Response = { | |||
| data: { | |||
| list: 顧客マスタ[]; | |||
| }; | |||
| } & APICommonResponse; | |||
| export const 顧客マスタ一覧取得 = async (param: 顧客マスタ一覧取得Request) => { | |||
| const res = await request<顧客マスタ一覧取得Response>({ | |||
| url: getUrl(ApiId.顧客マスタ一覧取得), | |||
| method: HttpMethod.GET, | |||
| data: new URLSearchParams(param), | |||
| }); | |||
| return res; | |||
| }; | |||
| @@ -0,0 +1,275 @@ | |||
| import { AxiosError, AxiosResponse } from "axios"; | |||
| import { format } from "date-fns"; | |||
| import { Dictionary, get } from "lodash"; | |||
| import { DataUrl } from "@types"; | |||
| import { setFormErrorMessages } from "components/hook-form"; | |||
| import axios from "utils/axios"; | |||
| let id = 0; | |||
| export const ApiId = { | |||
| // 共通--------------------------------------- | |||
| CSRF_TOKEN: id++, | |||
| // 認証関連 ---------------------------------- | |||
| ME: id++, | |||
| LOGIN: id++, | |||
| LOGOUT: id++, | |||
| // ログインユーザ関連 ---------------------------------- | |||
| 顧客ログインユーザ新規登録: id++, | |||
| 店舗ログインユーザ新規登録: id++, | |||
| 店舗ログインユーザ一覧取得: id++, | |||
| 顧客ログインユーザ一覧取得: id++, | |||
| // 顧客関連 ---------------------------------- | |||
| 顧客マスタ一覧取得: id++, | |||
| // 店舗関連関連 ---------------------------------- | |||
| 店舗一覧取得: id++, | |||
| 店舗新規登録: id++, | |||
| デポジット情報取得: id++, | |||
| デポジットチャージ: id++, | |||
| // QRサービス券関連------------------------------- | |||
| QRサービス券取得: id++, | |||
| } as const; | |||
| export type ApiId = (typeof ApiId)[keyof typeof ApiId]; | |||
| export const HttpMethod = { | |||
| GET: "get", | |||
| POST: "post", | |||
| } as const; | |||
| export type HttpMethod = (typeof HttpMethod)[keyof typeof HttpMethod]; | |||
| export const ResultCode = { | |||
| SUCCESS: 0, | |||
| FAILED: 1, | |||
| UNAUTHORIZED: 2, | |||
| EXCLUSIVE_ERROR: 3, | |||
| }; | |||
| export type ResultCode = (typeof ResultCode)[keyof typeof ResultCode]; | |||
| export interface TimestampRequest { | |||
| timestamp: string; | |||
| } | |||
| export type ListRequest = { | |||
| sort?: string; | |||
| order?: string; | |||
| }; | |||
| export interface APICommonResponse { | |||
| result: ResultCode; | |||
| messages: { | |||
| errors?: Dictionary<string>; | |||
| general?: string; | |||
| email_id?: number; | |||
| }; | |||
| } | |||
| export type ImagesResponse = { | |||
| data: { | |||
| images: DataUrl[]; | |||
| }; | |||
| } & APICommonResponse; | |||
| export const makeParam = <T extends object>(data: T): Dictionary<string> => { | |||
| const res: Dictionary<string> = {}; | |||
| Object.keys(data).map((key) => { | |||
| const val = get(data, key); | |||
| if (typeof val === "string" && val.length !== 0) { | |||
| res[key] = val; | |||
| } else if (typeof val === "number") { | |||
| res[key] = String(val); | |||
| } else if (typeof val === "boolean") { | |||
| res[key] = val ? "1" : "0"; | |||
| } else if (val instanceof Date) { | |||
| res[key] = format(val, "yyyy-MM-dd"); | |||
| } | |||
| }); | |||
| return res; | |||
| }; | |||
| export const makeFormData = <T extends object>(data: T): FormData => { | |||
| const res = new FormData(); | |||
| Object.keys(data).map((key) => { | |||
| const val = get(data, key); | |||
| if (typeof val === "string" && val.length !== 0) { | |||
| res.append(key, val); | |||
| } else if (typeof val === "number") { | |||
| res.append(key, String(val)); | |||
| } else if (typeof val === "boolean") { | |||
| res.append(key, val ? "1" : "0"); | |||
| } else if (val instanceof Date) { | |||
| res.append(key, format(val, "yyyy-MM-dd")); | |||
| } else if (val instanceof File) { | |||
| res.append(key, val); | |||
| } else if (Array.isArray(val)) { | |||
| val.forEach((v) => { | |||
| res.append(key + "[]", v); | |||
| }); | |||
| } else { | |||
| console.log("undefined data", key, val); | |||
| } | |||
| }); | |||
| return res; | |||
| }; | |||
| const isAxiosError = (error: any): error is AxiosError => { | |||
| return !!error.isAxiosError; | |||
| }; | |||
| type RequestArgument = { | |||
| url: string; | |||
| method: HttpMethod; | |||
| data?: URLSearchParams | FormData | object; | |||
| multipart?: boolean; | |||
| }; | |||
| export const request = async <T extends APICommonResponse>({ | |||
| url, | |||
| method, | |||
| data, | |||
| multipart, | |||
| }: RequestArgument): Promise<T | null> => { | |||
| let response: AxiosResponse<T | any> | null = null; | |||
| if (data instanceof URLSearchParams) { | |||
| data.sort(); | |||
| } | |||
| try { | |||
| if (multipart && data instanceof FormData) { | |||
| response = await axios({ | |||
| url, | |||
| method: "post", | |||
| data, | |||
| headers: { | |||
| "content-type": "multipart/form-data", | |||
| }, | |||
| }); | |||
| console.log("RESPONSE", url, "multipart", data, response?.data); | |||
| } else if (method === HttpMethod.GET) { | |||
| let searchUrl = url; | |||
| if (data instanceof URLSearchParams) { | |||
| searchUrl += "?" + data.toString(); | |||
| } | |||
| response = await axios.get<T>(searchUrl); | |||
| console.log(new Date(), "RESPONSE", searchUrl, method, response?.data); | |||
| } else if (method === HttpMethod.POST) { | |||
| response = await axios.post<T>(url, data); | |||
| let sendData: Dictionary<String> = {}; | |||
| if (data instanceof URLSearchParams) { | |||
| data.forEach((val, key) => { | |||
| sendData[key] = val; | |||
| }); | |||
| } else if (typeof data === "object" && !(data instanceof FormData)) { | |||
| Object.keys(data).forEach((key) => { | |||
| sendData[key] = get(data, key); | |||
| }); | |||
| } | |||
| console.log("RESPONSE", url, method, sendData, response?.data); | |||
| } else { | |||
| return null; | |||
| } | |||
| if (response && response.data.result === ResultCode.SUCCESS) { | |||
| return response.data; | |||
| } | |||
| return response?.data; | |||
| } catch (e) { | |||
| if (isAxiosError(e)) { | |||
| if (e.response?.status === 401 || e.response?.status === 419) { | |||
| window.location.reload(); | |||
| return null; | |||
| } | |||
| } | |||
| if (typeof e === "object") { | |||
| const message = get(e, "message"); | |||
| if (message === "Unauthenticated.") { | |||
| console.log("401!!!"); | |||
| window.location.reload(); | |||
| return null; | |||
| } | |||
| if (message === "CSRF token mismatch.") { | |||
| console.log("419!!!"); | |||
| window.location.reload(); | |||
| return null; | |||
| } | |||
| if (message === "Service Unavailable") { | |||
| console.log("503!!!"); | |||
| window.location.reload(); | |||
| return null; | |||
| } | |||
| } | |||
| throw e; | |||
| } | |||
| }; | |||
| export async function apiRequest< | |||
| T extends APICommonResponse, | |||
| U extends object | |||
| >({ | |||
| apiMethod, | |||
| sendData, | |||
| onSuccess, | |||
| onFailed, | |||
| onFinaly, | |||
| errorSetter, | |||
| setSending, | |||
| }: { | |||
| apiMethod: (sendData: U) => Promise<T | null>; | |||
| sendData: U; | |||
| onSuccess?: (res: T, sendData: U) => void; | |||
| onFailed?: (res: APICommonResponse | null) => void; | |||
| onFinaly?: (res: APICommonResponse | null) => void; | |||
| errorSetter?: any; | |||
| setSending?: (sending: boolean) => void; | |||
| }) { | |||
| if (setSending) { | |||
| setSending(true); | |||
| } | |||
| try { | |||
| const res = await apiMethod(sendData); | |||
| if (setSending) { | |||
| setSending(false); | |||
| } | |||
| if (res?.result === ResultCode.SUCCESS) { | |||
| if (onSuccess) { | |||
| onSuccess(res, sendData); | |||
| } | |||
| } else { | |||
| if (res?.messages.errors) { | |||
| if (errorSetter) { | |||
| const errorCount = setFormErrorMessages( | |||
| sendData, | |||
| errorSetter, | |||
| res.messages.errors | |||
| ); | |||
| console.log("FormErrorCount", errorCount); | |||
| } | |||
| } | |||
| if (onFailed) { | |||
| onFailed(res); | |||
| } | |||
| if (onFinaly) { | |||
| onFinaly(res); | |||
| } | |||
| } | |||
| return res; | |||
| } catch (e) { | |||
| if (setSending) { | |||
| setSending(false); | |||
| } | |||
| console.error(e); | |||
| if (onFailed) { | |||
| onFailed(null); | |||
| } | |||
| if (onFinaly) { | |||
| onFinaly(null); | |||
| } | |||
| return null; | |||
| } | |||
| } | |||
| @@ -0,0 +1,75 @@ | |||
| import { APICommonResponse, ApiId, HttpMethod, makeParam, request } from "api"; | |||
| import { getUrl } from "./url"; | |||
| type 運営会社ログインユーザ = { | |||
| id: string; | |||
| name: string; | |||
| email: string; | |||
| customer_code: string; | |||
| customer_name: string; | |||
| }; | |||
| // -------顧客ログインユーザ新規登録--------------- | |||
| export type 顧客ログインユーザ新規登録Request = { | |||
| name: string; | |||
| email: string; | |||
| password: string; | |||
| customerCode: string; | |||
| }; | |||
| export type 顧客ログインユーザ新規登録Response = { | |||
| data: { | |||
| user_id: string; | |||
| }; | |||
| } & APICommonResponse; | |||
| export const 顧客ログインユーザ新規登録 = async ( | |||
| param: 顧客ログインユーザ新規登録Request | |||
| ) => { | |||
| const res = await request<顧客ログインユーザ新規登録Response>({ | |||
| url: getUrl(ApiId.顧客ログインユーザ新規登録), | |||
| method: HttpMethod.POST, | |||
| data: makeParam(param), | |||
| }); | |||
| return res; | |||
| }; | |||
| // -------店舗ログインユーザ新規登録--------------- | |||
| export type 店舗ログインユーザ新規登録Request = { | |||
| shop_id: string; | |||
| name: string; | |||
| email: string; | |||
| password: string; | |||
| }; | |||
| export type 店舗ログインユーザ新規登録Response = { | |||
| data: { | |||
| user_id: string; | |||
| }; | |||
| } & APICommonResponse; | |||
| export const 店舗ログインユーザ新規登録 = async ( | |||
| param: 店舗ログインユーザ新規登録Request | |||
| ) => { | |||
| const res = await request<店舗ログインユーザ新規登録Response>({ | |||
| url: getUrl(ApiId.店舗ログインユーザ新規登録), | |||
| method: HttpMethod.POST, | |||
| data: makeParam(param), | |||
| }); | |||
| return res; | |||
| }; | |||
| // -------顧客ログインユーザ新規登録--------------- | |||
| export type 顧客一覧取得Request = { | |||
| email?: string; | |||
| name?: string; | |||
| }; | |||
| export type 顧客一覧取得Response = { | |||
| data: { | |||
| list: 運営会社ログインユーザ[]; | |||
| }; | |||
| } & APICommonResponse; | |||
| export const 顧客一覧取得 = async (param: 顧客一覧取得Request) => { | |||
| const res = await request<顧客一覧取得Response>({ | |||
| url: getUrl(ApiId.QRサービス券取得), | |||
| method: HttpMethod.GET, | |||
| data: new URLSearchParams(param), | |||
| }); | |||
| return res; | |||
| }; | |||
| @@ -0,0 +1,22 @@ | |||
| import { APICommonResponse, ApiId, HttpMethod, request } from "api"; | |||
| import { getUrl } from "./url"; | |||
| // -------QRサービス券取得--------------- | |||
| export type QRサービス券取得Request = { | |||
| token: string; | |||
| ticket_id?: string; | |||
| }; | |||
| export type QRサービス券取得Response = { | |||
| data: { | |||
| data: string; | |||
| ticket_id: string; | |||
| }; | |||
| } & APICommonResponse; | |||
| export const QRサービス券取得 = async (param: QRサービス券取得Request) => { | |||
| const res = await request<QRサービス券取得Response>({ | |||
| url: getUrl(ApiId.QRサービス券取得), | |||
| method: HttpMethod.GET, | |||
| data: new URLSearchParams(param), | |||
| }); | |||
| return res; | |||
| }; | |||
| @@ -0,0 +1,81 @@ | |||
| import { APICommonResponse, ApiId, HttpMethod, makeParam, request } from "api"; | |||
| import { getUrl } from "./url"; | |||
| type 店舗 = { | |||
| shp_id: string; | |||
| deposit: string; | |||
| name: string; | |||
| }; | |||
| // -------店舗一覧取得--------------- | |||
| export type 店舗一覧取得Request = { | |||
| email?: string; | |||
| name?: string; | |||
| }; | |||
| export type 店舗一覧取得Response = { | |||
| data: { | |||
| list: 店舗[]; | |||
| }; | |||
| } & APICommonResponse; | |||
| export const 店舗一覧取得 = async (param: 店舗一覧取得Request) => { | |||
| const res = await request<店舗一覧取得Response>({ | |||
| url: getUrl(ApiId.店舗一覧取得), | |||
| method: HttpMethod.GET, | |||
| data: new URLSearchParams(param), | |||
| }); | |||
| return res; | |||
| }; | |||
| // -------店舗新規登録--------------- | |||
| export type 店舗新規登録Request = { | |||
| name: string; | |||
| }; | |||
| export type 店舗新規登録Response = { | |||
| data: { | |||
| shop_id: string; | |||
| }; | |||
| } & APICommonResponse; | |||
| export const 店舗新規登録 = async (param: 店舗新規登録Request) => { | |||
| const res = await request<店舗新規登録Response>({ | |||
| url: getUrl(ApiId.店舗新規登録), | |||
| method: HttpMethod.POST, | |||
| data: makeParam(param), | |||
| }); | |||
| return res; | |||
| }; | |||
| // -------デポジット情報取得--------------- | |||
| export type デポジット情報取得Request = {}; | |||
| export type デポジット情報取得Response = { | |||
| data: { | |||
| deposit: number; | |||
| }; | |||
| } & APICommonResponse; | |||
| export const デポジット情報取得 = async (param: デポジット情報取得Request) => { | |||
| const res = await request<デポジット情報取得Response>({ | |||
| url: getUrl(ApiId.デポジット情報取得), | |||
| method: HttpMethod.GET, | |||
| }); | |||
| return res; | |||
| }; | |||
| // -------デポジットチャージ--------------- | |||
| export type デポジットチャージRequest = { | |||
| shop_id: string; | |||
| name: string; | |||
| email: string; | |||
| password: string; | |||
| }; | |||
| export type デポジットチャージResponse = { | |||
| data: { | |||
| shop_id: string; | |||
| deposit: number; | |||
| }; | |||
| } & APICommonResponse; | |||
| export const デポジットチャージ = async (param: デポジットチャージRequest) => { | |||
| const res = await request<デポジットチャージResponse>({ | |||
| url: getUrl(ApiId.デポジットチャージ), | |||
| method: HttpMethod.POST, | |||
| data: makeParam(param), | |||
| }); | |||
| return res; | |||
| }; | |||
| @@ -0,0 +1,51 @@ | |||
| import { HOST_API } from "config"; | |||
| import { ApiId as A } from "."; | |||
| const urls = { | |||
| // 共通--------------------------------------- | |||
| [A.CSRF_TOKEN]: "sanctum/csrf-cookie", | |||
| // 認証関連 ---------------------------------- | |||
| [A.ME]: "me", | |||
| [A.LOGIN]: "login", | |||
| [A.LOGOUT]: "logout", | |||
| // ログインユーザ関連 ---------------------------------- | |||
| [A.顧客ログインユーザ新規登録]: "login-user/customer/register", | |||
| [A.店舗ログインユーザ新規登録]: "login-user/shop/register", | |||
| [A.顧客ログインユーザ一覧取得]: "login-user/customer/list", | |||
| [A.店舗ログインユーザ一覧取得]: "login-user/shop/list", | |||
| // 顧客関連 ---------------------------------- | |||
| [A.顧客マスタ一覧取得]: "customer/list", | |||
| // 店舗関連関連 ---------------------------------- | |||
| [A.店舗一覧取得]: "shop/list", | |||
| [A.店舗新規登録]: "shop/register", | |||
| [A.デポジット情報取得]: "shop/deposit", | |||
| [A.デポジットチャージ]: "shop/deposit/charge", | |||
| // QRサービス券関連------------------------------- | |||
| [A.QRサービス券取得]: "qr-service/get-ticket", | |||
| }; | |||
| const prefixs = { | |||
| [A.CSRF_TOKEN]: "", | |||
| }; | |||
| const DEFAULT_API_URL_PREFIX = "api"; | |||
| const getPrefix = (apiId: A) => { | |||
| return prefixs[apiId] ?? DEFAULT_API_URL_PREFIX; | |||
| }; | |||
| export const getUrl = (apiId: A) => { | |||
| let url = getPrefix(apiId); | |||
| if (url.length !== 0) { | |||
| url += "/"; | |||
| } | |||
| return url + (urls[apiId] ?? ""); | |||
| }; | |||
| export const getFullUrl = (apiId: A) => { | |||
| return HOST_API + "/" + getUrl(apiId); | |||
| }; | |||
| @@ -0,0 +1,7 @@ | |||
| export const UserRole = { | |||
| NONE: "none", | |||
| SHOP: "shop", | |||
| CUSTOMER: "customer", | |||
| ADMIN: "admin", | |||
| } as const; | |||
| export type UserRole = (typeof UserRole)[keyof typeof UserRole]; | |||
| @@ -0,0 +1,26 @@ | |||
| import { PageID as P } from "pages"; | |||
| import { UserRole } from "./UserRole"; | |||
| const 共通ルート = [P.LOGIN, P.LOGOUT]; | |||
| const 認可別許可ルート: { | |||
| [route: string]: P[]; | |||
| } = { | |||
| [UserRole.NONE]: [...共通ルート], | |||
| [UserRole.ADMIN]: [...共通ルート, P.DASHBOARD_OVERVIEW], | |||
| [UserRole.CUSTOMER]: [...共通ルート, P.DASHBOARD_OVERVIEW], | |||
| [UserRole.SHOP]: [ | |||
| ...共通ルート, | |||
| P.DASHBOARD_OVERVIEW, | |||
| P.サービス券発行用QRコード, | |||
| P.サービス券利用履歴, | |||
| ], | |||
| }; | |||
| export const ページアクセス許可判定 = (role: UserRole, pageId: P): boolean => { | |||
| return !!認可別許可ルート[role].find((val) => { | |||
| return val === pageId; | |||
| }); | |||
| }; | |||
| @@ -0,0 +1,59 @@ | |||
| import { SelectOptionProps } from "components/hook-form/RHFSelect"; | |||
| export const prefCodeOptions: SelectOptionProps[] = [ | |||
| { value: "01", label: "北海道" }, | |||
| { value: "02", label: "青森県" }, | |||
| { value: "03", label: "岩手県" }, | |||
| { value: "04", label: "宮城県" }, | |||
| { value: "05", label: "秋田県" }, | |||
| { value: "06", label: "山形県" }, | |||
| { value: "07", label: "福島県" }, | |||
| { value: "08", label: "茨城県" }, | |||
| { value: "09", label: "栃木県" }, | |||
| { value: "10", label: "群馬県" }, | |||
| { value: "11", label: "埼玉県" }, | |||
| { value: "12", label: "千葉県" }, | |||
| { value: "13", label: "東京都" }, | |||
| { value: "14", label: "神奈川県" }, | |||
| { value: "15", label: "新潟県" }, | |||
| { value: "16", label: "富山県" }, | |||
| { value: "17", label: "石川県" }, | |||
| { value: "18", label: "福井県" }, | |||
| { value: "19", label: "山梨県" }, | |||
| { value: "20", label: "長野県" }, | |||
| { value: "21", label: "岐阜県" }, | |||
| { value: "22", label: "静岡県" }, | |||
| { value: "23", label: "愛知県" }, | |||
| { value: "24", label: "三重県" }, | |||
| { value: "25", label: "滋賀県" }, | |||
| { value: "26", label: "京都府" }, | |||
| { value: "27", label: "大阪府" }, | |||
| { value: "28", label: "兵庫県" }, | |||
| { value: "29", label: "奈良県" }, | |||
| { value: "30", label: "和歌山県" }, | |||
| { value: "31", label: "鳥取県" }, | |||
| { value: "32", label: "島根県" }, | |||
| { value: "33", label: "岡山県" }, | |||
| { value: "34", label: "広島県" }, | |||
| { value: "35", label: "山口県" }, | |||
| { value: "36", label: "徳島県" }, | |||
| { value: "37", label: "香川県" }, | |||
| { value: "38", label: "愛媛県" }, | |||
| { value: "39", label: "高知県" }, | |||
| { value: "40", label: "福岡県" }, | |||
| { value: "41", label: "佐賀県" }, | |||
| { value: "42", label: "長崎県" }, | |||
| { value: "43", label: "熊本県" }, | |||
| { value: "44", label: "大分県" }, | |||
| { value: "45", label: "宮崎県" }, | |||
| { value: "46", label: "鹿児島県" }, | |||
| { value: "47", label: "沖縄県" }, | |||
| ]; | |||
| export function getPrefName(code: string): string { | |||
| const pref = prefCodeOptions.find((ele) => { | |||
| return ele.value === code; | |||
| }); | |||
| return pref?.label ?? "-"; | |||
| } | |||
| @@ -0,0 +1,16 @@ | |||
| import { Box } from "@mui/material"; | |||
| import useBackDrop from "hooks/useBackDrop"; | |||
| import { useEffect } from "react"; | |||
| export default function LoadingScreen() { | |||
| const { setShowBackDrop, showBackDrop } = useBackDrop(); | |||
| useEffect(() => { | |||
| setShowBackDrop(true); | |||
| return () => { | |||
| setShowBackDrop(false); | |||
| }; | |||
| }, [showBackDrop]); | |||
| return <Box>Loading...</Box>; | |||
| } | |||
| @@ -0,0 +1,10 @@ | |||
| import { Chip, ChipProps } from "@mui/material"; | |||
| export type RequireChipProps = { require?: boolean } & ChipProps; | |||
| export default function RequireChip({ require, ...others }: RequireChipProps) { | |||
| if (require === false) { | |||
| return null; | |||
| } | |||
| return <Chip size="small" label="必須" color="error" {...others} />; | |||
| } | |||
| @@ -0,0 +1,83 @@ | |||
| import { Checkbox, CheckboxProps, FormControlLabel } from "@mui/material"; | |||
| import { Dictionary } from "@types"; | |||
| import { useMemo, useState } from "react"; | |||
| export type CheckBoxCustomProps = { | |||
| label: string; | |||
| value: boolean; | |||
| onFix?: () => void; | |||
| onChangeValue?: (val: boolean) => void; | |||
| messages?: Dictionary; | |||
| readonly?: boolean; | |||
| } & CheckboxProps; | |||
| export default function CheckBoxCustom({ | |||
| label, | |||
| value, | |||
| onFix, | |||
| onChangeValue, | |||
| messages, | |||
| readonly, | |||
| ...others | |||
| }: CheckBoxCustomProps) { | |||
| const [oldValue, setOldValue] = useState<string | null>(null); | |||
| const inputProps = useMemo(() => { | |||
| if (readonly) { | |||
| return { | |||
| style: { color: "rgb(50, 50, 50)" }, | |||
| disabled: true, | |||
| }; | |||
| } else { | |||
| return undefined; | |||
| } | |||
| }, [readonly]); | |||
| const fix = (newValue: string) => { | |||
| if (oldValue !== newValue) { | |||
| setOldValue(newValue); | |||
| if (onFix) { | |||
| onFix(); | |||
| } | |||
| } | |||
| }; | |||
| const handleChange = (e: any, val: boolean) => { | |||
| if (onChangeValue) { | |||
| onChangeValue(val); | |||
| } | |||
| }; | |||
| const message = useMemo(() => { | |||
| if (messages && others.name) { | |||
| return messages[others.name] ?? ""; | |||
| } else { | |||
| return ""; | |||
| } | |||
| }, [messages]); | |||
| const error = useMemo(() => { | |||
| if (messages && others.name) { | |||
| return ( | |||
| messages[others.name] !== undefined && | |||
| messages[others.name].length !== 0 | |||
| ); | |||
| } else { | |||
| return false; | |||
| } | |||
| }, [messages]); | |||
| return ( | |||
| <FormControlLabel | |||
| control={ | |||
| <Checkbox | |||
| checked={value} | |||
| onChange={handleChange} | |||
| inputProps={inputProps} | |||
| {...others} | |||
| /> | |||
| } | |||
| label={label} | |||
| /> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,45 @@ | |||
| import React from 'react'; | |||
| import { TextField, TextFieldProps } from '@mui/material'; | |||
| import { DatePicker } from '@mui/lab'; | |||
| import { TextFieldCustomProps } from './TextFieldCustom'; | |||
| import { isValid } from 'date-fns'; | |||
| type DatePickerCustomProps = TextFieldCustomProps & { | |||
| value: Date | null; | |||
| onChangeDate: (val: Date | null) => void; | |||
| }; | |||
| const DatePickerCustom = ({ label, value, onChangeDate, ...others }: DatePickerCustomProps) => { | |||
| const handleChange = (val: string | null) => { | |||
| console.log({ handleChange: val }); | |||
| if (onChangeDate) { | |||
| if (val !== null) { | |||
| const date = new Date(val); | |||
| if (isValid(date)) { | |||
| onChangeDate(date); | |||
| } else { | |||
| onChangeDate(null); | |||
| } | |||
| } else { | |||
| onChangeDate(null); | |||
| } | |||
| } | |||
| }; | |||
| const handleRender = (params: TextFieldProps) => { | |||
| return <TextField size="small" {...params} />; | |||
| }; | |||
| return ( | |||
| <DatePicker | |||
| label={label} | |||
| inputFormat="yyyy/MM/dd" | |||
| mask="____/__/__" | |||
| value={value} | |||
| onChange={handleChange} | |||
| renderInput={handleRender} | |||
| /> | |||
| ); | |||
| }; | |||
| export default React.memo(DatePickerCustom); | |||
| @@ -0,0 +1,61 @@ | |||
| import { Alert, SxProps } from "@mui/material"; | |||
| import { APIErrorType } from "hooks/useAPICall"; | |||
| import React, { useEffect, useMemo, useRef } from "react"; | |||
| import { FieldErrors, FieldValues, FormState } from "react-hook-form"; | |||
| type Props<T extends FieldValues> = { | |||
| error?: APIErrorType; | |||
| sx?: SxProps; | |||
| getMessage?: (errpr: APIErrorType) => string | null; | |||
| message?: string; | |||
| errorScroll?: boolean; | |||
| formState?: FormState<T>; | |||
| }; | |||
| const InputAlert = ({ | |||
| error, | |||
| sx, | |||
| getMessage, | |||
| message: errorMessage, | |||
| errorScroll, | |||
| formState, | |||
| }: Props<FieldValues>) => { | |||
| const ref = useRef<HTMLDivElement>(null); | |||
| const message = useMemo(() => { | |||
| if (errorMessage) { | |||
| return errorMessage; | |||
| } | |||
| if (getMessage && error) { | |||
| const m = getMessage(error); | |||
| if (m !== null) { | |||
| return m; | |||
| } | |||
| } | |||
| if (!!formState && Object.keys(formState.errors).length !== 0) | |||
| return "入力項目を確認してください。"; | |||
| if (error === APIErrorType.INPUT) return "入力項目を確認してください。"; | |||
| if (error === APIErrorType.SERVER) | |||
| return "エラーが発生しております。しばらくお待ちください。"; | |||
| if (error === APIErrorType.EXCLUSIVE) | |||
| return "ページの期限が切れています。再度読込を行ってください"; | |||
| return ""; | |||
| }, [error, errorMessage, getMessage, formState]); | |||
| // エラー時に自動的にスクロール制御 | |||
| useEffect(() => { | |||
| if (error !== APIErrorType.NONE && errorScroll) { | |||
| if (ref.current) { | |||
| ref.current.scrollIntoView({ block: "center", behavior: "smooth" }); | |||
| } | |||
| } | |||
| }, [error, ref]); | |||
| if (message === "") return null; | |||
| return ( | |||
| <Alert severity="error" sx={sx} ref={ref}> | |||
| {message} | |||
| </Alert> | |||
| ); | |||
| }; | |||
| export default React.memo(InputAlert); | |||
| @@ -0,0 +1,95 @@ | |||
| import React, { useMemo, useState } from "react"; | |||
| import { TextField, TextFieldProps } from "@mui/material"; | |||
| import { Dictionary } from "@types"; | |||
| export type TextFieldCustomProps = { | |||
| onFix?: () => void; | |||
| onChangeValue?: (val: string) => void; | |||
| messages?: Dictionary; | |||
| readonly?: boolean; | |||
| } & TextFieldProps; | |||
| export default function TextFieldCustom({ | |||
| onFix, | |||
| onChangeValue, | |||
| messages, | |||
| readonly, | |||
| ...others | |||
| }: TextFieldCustomProps) { | |||
| const [oldValue, setOldValue] = useState<string | null>(null); | |||
| const inputProps = useMemo(() => { | |||
| if (readonly) { | |||
| return { | |||
| style: { color: "rgb(50, 50, 50)" }, | |||
| disabled: true, | |||
| }; | |||
| } else { | |||
| return undefined; | |||
| } | |||
| }, [readonly]); | |||
| const fix = (newValue: string) => { | |||
| if (oldValue !== newValue) { | |||
| setOldValue(newValue); | |||
| if (onFix) { | |||
| onFix(); | |||
| } | |||
| } | |||
| }; | |||
| const handleEnter = (e: React.KeyboardEvent<HTMLInputElement>) => { | |||
| if (e.key === "Enter") { | |||
| if (e.target instanceof HTMLInputElement) { | |||
| fix(e.target.value); | |||
| } | |||
| } | |||
| }; | |||
| const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => { | |||
| if (!others.select) { | |||
| fix(e.target.value); | |||
| } | |||
| }; | |||
| const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |||
| if (onChangeValue) { | |||
| onChangeValue(e.target.value); | |||
| } | |||
| if (others.select) { | |||
| fix(e.target.value); | |||
| } | |||
| }; | |||
| const message = useMemo(() => { | |||
| if (messages && others.name) { | |||
| return messages[others.name] ?? ""; | |||
| } else { | |||
| return ""; | |||
| } | |||
| }, [messages]); | |||
| const error = useMemo(() => { | |||
| if (messages && others.name) { | |||
| return ( | |||
| messages[others.name] !== undefined && | |||
| messages[others.name].length !== 0 | |||
| ); | |||
| } else { | |||
| return false; | |||
| } | |||
| }, [messages]); | |||
| return ( | |||
| <TextField | |||
| size="small" | |||
| onKeyDown={handleEnter} | |||
| onBlur={handleBlur} | |||
| onChange={handleChange} | |||
| helperText={message} | |||
| error={error} | |||
| inputProps={inputProps} | |||
| {...others} | |||
| /> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,36 @@ | |||
| import React, { useMemo, useState } from 'react'; | |||
| import { TextField, TextFieldProps } from '@mui/material'; | |||
| export type TextFieldExProps = { | |||
| readOnly?: boolean; | |||
| } & TextFieldProps; | |||
| const TextFieldEx = ({ readOnly, ...others }: TextFieldExProps) => { | |||
| if (readOnly) { | |||
| const props: any = {}; | |||
| if (typeof others.value === 'string' && others.value.length === 0) { | |||
| props.value = ' '; | |||
| } | |||
| return ( | |||
| <TextField | |||
| {...others} | |||
| sx={{ | |||
| input: { | |||
| WebkitTextFillColor: 'black !important', | |||
| }, | |||
| textarea: { | |||
| WebkitTextFillColor: 'black !important', | |||
| }, | |||
| }} | |||
| disabled | |||
| variant="standard" | |||
| {...props} | |||
| /> | |||
| ); | |||
| } | |||
| return <TextField {...others} />; | |||
| }; | |||
| export default React.memo(TextFieldEx); | |||
| @@ -0,0 +1,24 @@ | |||
| import { Button } from '@mui/material'; | |||
| import { ReactNode } from 'react'; | |||
| // form | |||
| import { FormProvider as Form, UseFormReturn } from 'react-hook-form'; | |||
| // ---------------------------------------------------------------------- | |||
| type Props = { | |||
| children: ReactNode; | |||
| methods: UseFormReturn<any>; | |||
| onSubmit?: VoidFunction; | |||
| }; | |||
| export default function FormProvider({ children, onSubmit, methods }: Props) { | |||
| return ( | |||
| <Form {...methods}> | |||
| <form onSubmit={onSubmit}> | |||
| {children} | |||
| {/* エンターでsubmitできるようにする */} | |||
| <Button type="submit" sx={{ display: 'none' }} /> | |||
| </form> | |||
| </Form> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,121 @@ | |||
| import { useFormContext, Controller } from 'react-hook-form'; | |||
| import { Autocomplete, TextField, TextFieldProps } from '@mui/material'; | |||
| import React, { useEffect, useMemo } from 'react'; | |||
| import TextFieldEx from '../form/TextFieldEx'; | |||
| // ---------------------------------------------------------------------- | |||
| export type AutoCompleteOption = { | |||
| label: string; | |||
| value: string; | |||
| }; | |||
| export type AutoCompleteOptionType = AutoCompleteOption | string | null; | |||
| export const getValue = (option: AutoCompleteOptionType): string => { | |||
| if (option === null) { | |||
| return ''; | |||
| } | |||
| if (typeof option === 'object') { | |||
| return option.value; | |||
| } | |||
| if (typeof option === 'string') { | |||
| return option; | |||
| } | |||
| return ''; | |||
| }; | |||
| type IProps = { | |||
| name: string; | |||
| /** | |||
| * undefined の場合は、オプション確定前と単断する | |||
| */ | |||
| options?: AutoCompleteOption[]; | |||
| onFix?: VoidFunction; | |||
| readOnly?: boolean; | |||
| }; | |||
| type Props = IProps & TextFieldProps; | |||
| export type RHFAutoCompleteProps = Props; | |||
| export const getAutoCompleteOption = ( | |||
| options: AutoCompleteOption[], | |||
| value: string | |||
| ): AutoCompleteOptionType => { | |||
| return options.find((option) => option.value === value) ?? null; | |||
| }; | |||
| export default function RHFAutoComplete({ name, options, onFix, readOnly, ...other }: Props) { | |||
| const { control, watch, setValue } = useFormContext(); | |||
| const value: AutoCompleteOption | string | null = watch(name); | |||
| const valueStr = useMemo(() => { | |||
| if (value === null) return ''; | |||
| if (value === undefined) return ''; | |||
| if (typeof value === 'string') { | |||
| return value; | |||
| } else { | |||
| return value.label ?? ''; | |||
| } | |||
| }, [value]); | |||
| // string型からAutoCompleteOptionへ変換してフォームへセットする | |||
| useEffect(() => { | |||
| if (typeof value === 'string' && options) { | |||
| if (value === '') { | |||
| setValue(name, null); | |||
| } else { | |||
| const val = getAutoCompleteOption(options, value); | |||
| if (val !== null) { | |||
| setValue(name, val); | |||
| } | |||
| } | |||
| } | |||
| }, [value, options]); | |||
| if (readOnly) { | |||
| return <TextFieldEx readOnly {...other} value={valueStr} variant="standard" />; | |||
| } | |||
| if (typeof value === 'string') return null; | |||
| return ( | |||
| <Controller | |||
| name={name} | |||
| control={control} | |||
| render={({ field: { onChange, onBlur, value, ref }, fieldState }) => ( | |||
| <Autocomplete | |||
| options={options ?? []} | |||
| fullWidth | |||
| autoComplete | |||
| includeInputInList | |||
| noOptionsText="候補がありません" | |||
| getOptionLabel={(option) => option?.label ?? ''} | |||
| isOptionEqualToValue={(option, value) => { | |||
| // if (typeof value !== 'object') return false; | |||
| return option.value === value.value; | |||
| }} | |||
| onChange={(e, item) => { | |||
| onChange(item); | |||
| if (onFix) { | |||
| onFix(); | |||
| } | |||
| }} | |||
| value={value} | |||
| renderInput={(params) => ( | |||
| <TextField | |||
| {...params} | |||
| fullWidth | |||
| error={fieldState.invalid} | |||
| helperText={fieldState.error?.message} | |||
| {...other} | |||
| /> | |||
| )} | |||
| ChipProps={{ | |||
| style: { | |||
| margin: 0, | |||
| }, | |||
| }} | |||
| /> | |||
| )} | |||
| /> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,137 @@ | |||
| // form | |||
| import { useFormContext, Controller } from "react-hook-form"; | |||
| // @mui | |||
| import { | |||
| Checkbox, | |||
| FormControlLabel, | |||
| FormGroup, | |||
| FormControlLabelProps, | |||
| Typography, | |||
| Stack, | |||
| Alert, | |||
| } from "@mui/material"; | |||
| import { useMemo } from "react"; | |||
| // ---------------------------------------------------------------------- | |||
| interface RHFCheckboxProps extends Omit<FormControlLabelProps, "control"> { | |||
| name: string; | |||
| readOnly?: boolean; | |||
| } | |||
| export function RHFCheckbox({ name, readOnly, ...other }: RHFCheckboxProps) { | |||
| const { control, watch } = useFormContext(); | |||
| const _formValue = watch(name); | |||
| // const formValue : boolean = useMemo(()=>{ | |||
| // if(_formValue typeof 'boolean') { | |||
| // return _formValue; | |||
| // } | |||
| // return false | |||
| // },[_formValue]) | |||
| const formValue = useMemo(() => { | |||
| if (typeof _formValue === "boolean") { | |||
| return _formValue; | |||
| } | |||
| if (typeof _formValue === "string") { | |||
| return _formValue === "1" || _formValue === "true"; | |||
| } | |||
| console.log("else"); | |||
| return false; | |||
| }, [_formValue]); | |||
| if (readOnly) { | |||
| return ( | |||
| <FormControlLabel | |||
| control={ | |||
| <Checkbox | |||
| disableRipple | |||
| disableTouchRipple | |||
| disableFocusRipple | |||
| checked={formValue} | |||
| sx={{ | |||
| cursor: "default", | |||
| }} | |||
| /> | |||
| } | |||
| sx={{ | |||
| cursor: "default", | |||
| }} | |||
| {...other} | |||
| /> | |||
| ); | |||
| } | |||
| return ( | |||
| <FormControlLabel | |||
| control={ | |||
| <Controller | |||
| name={name} | |||
| control={control} | |||
| render={({ field }) => <Checkbox {...field} checked={formValue} />} | |||
| /> | |||
| } | |||
| {...other} | |||
| /> | |||
| ); | |||
| } | |||
| // ---------------------------------------------------------------------- | |||
| interface RHFMultiCheckboxProps | |||
| extends Omit<FormControlLabelProps, "control" | "label"> { | |||
| name: string; | |||
| options: { | |||
| label: string; | |||
| value: any; | |||
| }[]; | |||
| } | |||
| export function RHFMultiCheckbox({ | |||
| name, | |||
| options, | |||
| ...other | |||
| }: RHFMultiCheckboxProps) { | |||
| const { control } = useFormContext(); | |||
| return ( | |||
| <Controller | |||
| name={name} | |||
| control={control} | |||
| render={({ field, fieldState: { error } }) => { | |||
| const onSelected = (option: string) => | |||
| field.value.includes(option) | |||
| ? field.value.filter((value: string) => value !== option) | |||
| : [...field.value, option]; | |||
| return ( | |||
| <Stack> | |||
| {!!error && <Alert severity="warning">{error.message}</Alert>} | |||
| <FormGroup> | |||
| {options.map((option) => ( | |||
| <FormControlLabel | |||
| key={option.value} | |||
| control={ | |||
| <Checkbox | |||
| checked={field.value.includes(option.value)} | |||
| onChange={() => field.onChange(onSelected(option.value))} | |||
| color="error" | |||
| /> | |||
| } | |||
| label={ | |||
| <Typography variant="body2">{option.label}</Typography> | |||
| } | |||
| {...other} | |||
| /> | |||
| ))} | |||
| </FormGroup> | |||
| </Stack> | |||
| ); | |||
| }} | |||
| /> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,81 @@ | |||
| import { | |||
| Controller, | |||
| ControllerFieldState, | |||
| ControllerRenderProps, | |||
| FieldValues, | |||
| useFormContext, | |||
| } from "react-hook-form"; | |||
| import { TextFieldProps } from "@mui/material"; | |||
| import { DatePicker } from "@mui/x-date-pickers"; | |||
| import React, { useMemo } from "react"; | |||
| import RHFTextField from "./RHFTextField"; | |||
| // ---------------------------------------------------------------------- | |||
| type IProps = { | |||
| name: string; | |||
| readOnly?: boolean; | |||
| datePickerProps?: any; | |||
| }; | |||
| type Props = IProps & TextFieldProps; | |||
| export type RHFDatePickerProps = Props; | |||
| const RHFDatePicker = ({ | |||
| name, | |||
| readOnly, | |||
| datePickerProps, | |||
| ...other | |||
| }: Props) => { | |||
| const { | |||
| control, | |||
| watch, | |||
| setValue, | |||
| formState: { errors }, | |||
| } = useFormContext(); | |||
| const value: Date | null = watch(name); | |||
| const isError = useMemo(() => { | |||
| return !!errors[name]; | |||
| }, [errors, name]); | |||
| const errorMessage = useMemo(() => { | |||
| return errors[name]?.message ?? ""; | |||
| }, [errors, name]); | |||
| if (readOnly) { | |||
| return <RHFTextField name={name} readOnly {...other} variant="standard" />; | |||
| } | |||
| const render = ({ | |||
| field, | |||
| fieldState, | |||
| }: { | |||
| field: ControllerRenderProps<FieldValues, string>; | |||
| fieldState: ControllerFieldState; | |||
| }) => { | |||
| return ( | |||
| <DatePicker | |||
| slotProps={{ | |||
| actionBar: { | |||
| actions: ["accept", "clear", "cancel"], | |||
| }, | |||
| inputAdornment: { | |||
| position: "start", | |||
| }, | |||
| textField: { | |||
| size: "small", | |||
| error: isError, | |||
| helperText: errorMessage, | |||
| }, | |||
| }} | |||
| {...field} | |||
| {...datePickerProps} | |||
| /> | |||
| ); | |||
| }; | |||
| return <Controller name={name} control={control} render={render} />; | |||
| }; | |||
| export default React.memo(RHFDatePicker); | |||
| @@ -0,0 +1,53 @@ | |||
| // form | |||
| import { useFormContext, Controller } from 'react-hook-form'; | |||
| // @mui | |||
| import { | |||
| Radio, | |||
| RadioGroup, | |||
| FormHelperText, | |||
| RadioGroupProps, | |||
| FormControlLabel, | |||
| } from '@mui/material'; | |||
| // ---------------------------------------------------------------------- | |||
| type IProps = { | |||
| name: string; | |||
| options: { | |||
| label: string; | |||
| value: any; | |||
| }[]; | |||
| }; | |||
| type Props = IProps & RadioGroupProps; | |||
| export default function RHFRadioGroup({ name, options, ...other }: Props) { | |||
| const { control } = useFormContext(); | |||
| return ( | |||
| <Controller | |||
| name={name} | |||
| control={control} | |||
| render={({ field, fieldState: { error } }) => ( | |||
| <div> | |||
| <RadioGroup {...field} row {...other}> | |||
| {options.map((option) => ( | |||
| <FormControlLabel | |||
| key={option.value} | |||
| value={option.value} | |||
| control={<Radio />} | |||
| label={option.label} | |||
| /> | |||
| ))} | |||
| </RadioGroup> | |||
| {!!error && ( | |||
| <FormHelperText error sx={{ px: 2 }}> | |||
| {error.message} | |||
| </FormHelperText> | |||
| )} | |||
| </div> | |||
| )} | |||
| /> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,221 @@ | |||
| import { useFormContext, Controller } from "react-hook-form"; | |||
| import { | |||
| Box, | |||
| Chip, | |||
| FormControl, | |||
| FormHelperText, | |||
| IconButton, | |||
| MenuItem, | |||
| OutlinedInput, | |||
| Select, | |||
| TextField, | |||
| TextFieldProps, | |||
| } from "@mui/material"; | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import TextFieldEx from "../form/TextFieldEx"; | |||
| import { Dictionary } from "@types"; | |||
| import { Clear, Label } from "@mui/icons-material"; | |||
| // ---------------------------------------------------------------------- | |||
| export type SelectOptionProps = { | |||
| value: string; | |||
| label?: string; | |||
| }; | |||
| export const makeOptions = (list: Dictionary[]): SelectOptionProps[] => { | |||
| const ret: SelectOptionProps[] = []; | |||
| list.forEach((dic) => { | |||
| const value = Object.keys(dic)[0]; | |||
| const label = dic[value]; | |||
| ret.push({ | |||
| value, | |||
| label, | |||
| }); | |||
| }); | |||
| return ret; | |||
| }; | |||
| type IProps = { | |||
| name: string; | |||
| children?: any; | |||
| readOnly?: boolean; | |||
| options?: SelectOptionProps[]; | |||
| onFix?: VoidFunction; | |||
| }; | |||
| type Props = IProps & TextFieldProps; | |||
| export type RHFSelectProps = Props; | |||
| export default function RHFSelect({ | |||
| name, | |||
| children, | |||
| readOnly, | |||
| options, | |||
| onFix, | |||
| ...other | |||
| }: Props) { | |||
| const { control, watch } = useFormContext(); | |||
| const formValue: string = watch(name); | |||
| const [isMounted, setIsMounted] = useState(false); | |||
| const getOptionElements = useCallback(() => { | |||
| if (options) { | |||
| if (options.length === 0) { | |||
| return [ | |||
| <MenuItem key="disabled" disabled> | |||
| 候補がありません | |||
| </MenuItem>, | |||
| ]; | |||
| } else { | |||
| return options.map((option, index) => { | |||
| return ( | |||
| <MenuItem value={option.value} key={index}> | |||
| {option.label ?? option.value} | |||
| </MenuItem> | |||
| ); | |||
| }); | |||
| } | |||
| } | |||
| return []; | |||
| }, [options]); | |||
| const getLabel = useMemo(() => { | |||
| if (!options) return ""; | |||
| return ( | |||
| options.find((option) => { | |||
| return option.value === formValue; | |||
| })?.label ?? " " | |||
| ); | |||
| }, [formValue, options]); | |||
| useEffect(() => { | |||
| if (isMounted && onFix) { | |||
| onFix(); | |||
| } | |||
| setIsMounted(true); | |||
| }, [formValue]); | |||
| if (readOnly) { | |||
| return ( | |||
| <TextFieldEx readOnly {...other} value={getLabel} variant="standard" /> | |||
| ); | |||
| } | |||
| if (!options) { | |||
| return null; | |||
| } | |||
| return ( | |||
| <Controller | |||
| name={name} | |||
| control={control} | |||
| render={({ field, fieldState: { error } }) => ( | |||
| <TextField | |||
| {...field} | |||
| select | |||
| fullWidth | |||
| error={!!error} | |||
| helperText={error?.message} | |||
| {...other} | |||
| InputLabelProps={{ shrink: true }} | |||
| SelectProps={{}} | |||
| > | |||
| {children} | |||
| {getOptionElements()} | |||
| </TextField> | |||
| )} | |||
| /> | |||
| ); | |||
| } | |||
| const ITEM_HEIGHT = 48; | |||
| const ITEM_PADDING_TOP = 8; | |||
| const MenuProps = { | |||
| PaperProps: { | |||
| style: { | |||
| maxHeight: ITEM_HEIGHT * 4.5 + ITEM_PADDING_TOP, | |||
| width: 250, | |||
| }, | |||
| }, | |||
| }; | |||
| export function RHFSelectMuiliple({ | |||
| name, | |||
| children, | |||
| readOnly, | |||
| options, | |||
| onFix, | |||
| ...other | |||
| }: Props) { | |||
| const { control, watch, setValue } = useFormContext(); | |||
| const formValue: string[] = watch(name); | |||
| const getOptionElements = useCallback(() => { | |||
| if (options) { | |||
| if (options.length === 0) { | |||
| return [ | |||
| <MenuItem key="disabled" disabled> | |||
| 候補がありません | |||
| </MenuItem>, | |||
| ]; | |||
| } else { | |||
| return options.map((option, index) => { | |||
| return ( | |||
| <MenuItem value={option.value} key={index}> | |||
| {option.label ?? option.value} | |||
| </MenuItem> | |||
| ); | |||
| }); | |||
| } | |||
| } | |||
| return []; | |||
| }, [options]); | |||
| const clearButton = useMemo(() => { | |||
| if (formValue.length === 0) return null; | |||
| const handleClick = () => { | |||
| setValue(name, []); | |||
| }; | |||
| return ( | |||
| <IconButton sx={{ mr: 1 }} onClick={handleClick}> | |||
| <Clear /> | |||
| </IconButton> | |||
| ); | |||
| }, [formValue]); | |||
| return ( | |||
| <Controller | |||
| name={name} | |||
| control={control} | |||
| render={({ field, fieldState: { error } }) => ( | |||
| <FormControl> | |||
| <Select | |||
| {...field} | |||
| size="small" | |||
| multiple | |||
| value={formValue} | |||
| error={!!error} | |||
| input={<OutlinedInput endAdornment={clearButton} />} | |||
| renderValue={(selected) => ( | |||
| <Box sx={{ display: "flex", flexWrap: "wrap", gap: 0.5 }}> | |||
| {selected.map((value) => ( | |||
| <Chip key={value} label={value} size="small" /> | |||
| ))} | |||
| </Box> | |||
| )} | |||
| MenuProps={MenuProps} | |||
| > | |||
| {children} | |||
| {getOptionElements()} | |||
| </Select> | |||
| {!!error?.message && <FormHelperText>{error.message}</FormHelperText>} | |||
| </FormControl> | |||
| )} | |||
| /> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,29 @@ | |||
| // form | |||
| import { useFormContext, Controller } from 'react-hook-form'; | |||
| // @mui | |||
| import { Switch, FormControlLabel, FormControlLabelProps } from '@mui/material'; | |||
| // ---------------------------------------------------------------------- | |||
| type IProps = Omit<FormControlLabelProps, 'control'>; | |||
| interface Props extends IProps { | |||
| name: string; | |||
| } | |||
| export default function RHFSwitch({ name, ...other }: Props) { | |||
| const { control } = useFormContext(); | |||
| return ( | |||
| <FormControlLabel | |||
| control={ | |||
| <Controller | |||
| name={name} | |||
| control={control} | |||
| render={({ field }) => <Switch {...field} checked={field.value} />} | |||
| /> | |||
| } | |||
| {...other} | |||
| /> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,66 @@ | |||
| import { useFormContext, Controller } from "react-hook-form"; | |||
| import { TextField, TextFieldProps } from "@mui/material"; | |||
| import { useMemo } from "react"; | |||
| import { formatDateStr } from "utils/datetime"; | |||
| import TextFieldEx from "../form/TextFieldEx"; | |||
| // ---------------------------------------------------------------------- | |||
| type IProps = { | |||
| name: string; | |||
| readOnly?: boolean; | |||
| }; | |||
| type Props = IProps & TextFieldProps; | |||
| export type RHFTextFieldProps = Props; | |||
| export default function RHFTextField({ | |||
| name, | |||
| readOnly, | |||
| size: fieldSize = "small", | |||
| ...other | |||
| }: Props) { | |||
| const { control, watch } = useFormContext(); | |||
| const value = watch(name); | |||
| const valueStr = useMemo(() => { | |||
| if (typeof value === "string") { | |||
| if (value === "") { | |||
| return " "; | |||
| } else { | |||
| return value; | |||
| } | |||
| } | |||
| if (value instanceof Date) { | |||
| return formatDateStr(value); | |||
| } | |||
| if (readOnly) { | |||
| return " "; | |||
| } | |||
| return ""; | |||
| }, [value]); | |||
| if (readOnly) { | |||
| return ( | |||
| <TextFieldEx readOnly {...other} value={valueStr} variant="standard" /> | |||
| ); | |||
| } | |||
| return ( | |||
| <Controller | |||
| name={name} | |||
| control={control} | |||
| render={({ field, fieldState: { error } }) => ( | |||
| <TextField | |||
| {...field} | |||
| fullWidth | |||
| error={!!error} | |||
| helperText={error?.message} | |||
| {...other} | |||
| size={fieldSize} | |||
| /> | |||
| )} | |||
| /> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,103 @@ | |||
| import { Box, Button, Stack, Typography, styled } from "@mui/material"; | |||
| import { useEffect, useRef, useState } from "react"; | |||
| import { Controller, useFormContext } from "react-hook-form"; | |||
| interface Props { | |||
| name: string; | |||
| onChangeFile?: (param: { imageData: string; fileName: string }) => void; | |||
| } | |||
| export function RHFUpload({ name, onChangeFile }: Props) { | |||
| const fileInput = useRef<HTMLInputElement | null>(null); | |||
| const [fileName, setFileName] = useState(""); | |||
| const [imageData, setImageData] = useState(""); | |||
| const { register, setValue, watch } = useFormContext(); | |||
| const file: File[] = watch(name); | |||
| const onChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |||
| const files = e.target.files; | |||
| if (!files || files.length <= 0) return; | |||
| deployment(files); | |||
| }; | |||
| const { ref, ...rest } = register(name, { | |||
| onChange, | |||
| }); | |||
| const selectFile = () => { | |||
| if (!fileInput.current) return; | |||
| fileInput.current.removeAttribute("capture"); | |||
| fileInput.current.click(); | |||
| }; | |||
| // ファイルを選択した時の処理 | |||
| const deployment = (files: FileList) => { | |||
| const file = files[0]; | |||
| const fileReader = new FileReader(); | |||
| setFileName(file.name); | |||
| fileReader.onload = () => { | |||
| setImageData(fileReader.result as string); | |||
| if (onChangeFile) { | |||
| onChangeFile({ imageData, fileName }); | |||
| } | |||
| }; | |||
| fileReader.readAsDataURL(file); | |||
| }; | |||
| const reset = () => { | |||
| setValue(name, []); | |||
| }; | |||
| useEffect(() => { | |||
| if (file.length === 0) { | |||
| setFileName(""); | |||
| setImageData(""); | |||
| } | |||
| }, [file]); | |||
| return ( | |||
| <> | |||
| <div> | |||
| <input | |||
| type="file" | |||
| id="file" | |||
| ref={(e) => { | |||
| ref(e); | |||
| fileInput.current = e; | |||
| }} | |||
| accept="image/*" | |||
| style={{ display: "none" }} | |||
| {...rest} | |||
| /> | |||
| <button onClick={selectFile} type="button"> | |||
| 📁 ファイルから選択 | |||
| </button> | |||
| </div> | |||
| <div> | |||
| {fileName && ( | |||
| <> | |||
| <Box position="relative"> | |||
| <img | |||
| src={imageData} | |||
| style={{ margin: "auto", maxWidth: "100%" }} | |||
| /> | |||
| <button | |||
| onClick={reset} | |||
| type="button" | |||
| style={{ | |||
| position: "absolute", | |||
| top: 0, | |||
| right: 0, | |||
| }} | |||
| > | |||
| ❌ | |||
| </button> | |||
| <div>{fileName}</div> | |||
| </Box> | |||
| </> | |||
| )} | |||
| </div> | |||
| </> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,6 @@ | |||
| import { prefCodeOptions } from "codes/prefcode"; | |||
| import RHFSelect, { RHFSelectProps, SelectOptionProps } from "../RHFSelect"; | |||
| export default function RHFPrefCodeSelect({ ...other }: RHFSelectProps) { | |||
| return <RHFSelect {...other} options={prefCodeOptions} />; | |||
| } | |||
| @@ -0,0 +1,33 @@ | |||
| import { Dictionary } from "@types"; | |||
| export * from "./RHFCheckbox"; | |||
| export { default as FormProvider } from "./FormProvider"; | |||
| export { default as RHFSwitch } from "./RHFSwitch"; | |||
| export { default as RHFSelect } from "./RHFSelect"; | |||
| export { default as RHFTextField } from "./RHFTextField"; | |||
| export { default as RHFRadioGroup } from "./RHFRadioGroup"; | |||
| export { default as RHFAutoComplete } from "./RHFAutoComplete"; | |||
| /** | |||
| * | |||
| * @param formData object | |||
| * @param setter RHFの関数setError | |||
| * @param messages Dictionary | |||
| */ | |||
| export function setFormErrorMessages( | |||
| formData: object, | |||
| setter: any, | |||
| messages: Dictionary | |||
| ) { | |||
| let count = 0; | |||
| const keys = Object.keys(formData); | |||
| Object.keys(messages).forEach((name) => { | |||
| if (keys.includes(name)) { | |||
| setter(name, { message: messages[name] }); | |||
| count++; | |||
| } | |||
| }); | |||
| return count; | |||
| } | |||
| @@ -0,0 +1,18 @@ | |||
| import { Stack, StackProps } from "@mui/material"; | |||
| import { HasChildren } from "@types"; | |||
| export type StackRowProps = { | |||
| show?: boolean; | |||
| } & StackProps & | |||
| HasChildren; | |||
| export default function StackRow(props: StackRowProps) { | |||
| const { show } = props; | |||
| if (show === false) { | |||
| return null; | |||
| } | |||
| return ( | |||
| <Stack spacing={1} {...props} direction="row"> | |||
| {props.children} | |||
| </Stack> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,107 @@ | |||
| // @mui | |||
| import { Theme } from "@mui/material/styles"; | |||
| import { | |||
| Box, | |||
| SxProps, | |||
| Checkbox, | |||
| TableRow, | |||
| TableCell, | |||
| TableHead, | |||
| TableSortLabel, | |||
| } from "@mui/material"; | |||
| // ---------------------------------------------------------------------- | |||
| const visuallyHidden = { | |||
| border: 0, | |||
| margin: -1, | |||
| padding: 0, | |||
| width: "1px", | |||
| height: "1px", | |||
| overflow: "hidden", | |||
| position: "absolute", | |||
| whiteSpace: "nowrap", | |||
| clip: "rect(0 0 0 0)", | |||
| } as const; | |||
| // ---------------------------------------------------------------------- | |||
| export type HeadLabelProps = { | |||
| id: string; | |||
| label?: string; | |||
| align?: "inherit" | "left" | "center" | "right" | "justify"; | |||
| width?: number | string; | |||
| minWidth?: number | string; | |||
| needSort?: boolean; | |||
| // { id: "customer_name", label: "運営会社名", align: "left" }, | |||
| }; | |||
| type Props = { | |||
| order?: "asc" | "desc"; | |||
| orderBy?: string; | |||
| headLabel: HeadLabelProps[]; | |||
| rowCount?: number; | |||
| numSelected?: number; | |||
| onSort?: (id: string) => void; | |||
| onSelectAllRows?: (checked: boolean) => void; | |||
| sx?: SxProps<Theme>; | |||
| }; | |||
| export default function TableHeadCustom({ | |||
| order, | |||
| orderBy, | |||
| rowCount = 0, | |||
| headLabel, | |||
| numSelected = 0, | |||
| onSort, | |||
| onSelectAllRows, | |||
| sx, | |||
| }: Props) { | |||
| return ( | |||
| <TableHead sx={sx}> | |||
| <TableRow> | |||
| {onSelectAllRows && ( | |||
| <TableCell padding="checkbox"> | |||
| <Checkbox | |||
| indeterminate={numSelected > 0 && numSelected < rowCount} | |||
| checked={rowCount > 0 && numSelected === rowCount} | |||
| onChange={(event: React.ChangeEvent<HTMLInputElement>) => | |||
| onSelectAllRows(event.target.checked) | |||
| } | |||
| /> | |||
| </TableCell> | |||
| )} | |||
| {headLabel.map((headCell) => ( | |||
| <TableCell | |||
| key={headCell.id} | |||
| align={headCell.align || "left"} | |||
| sortDirection={orderBy === headCell.id ? order : false} | |||
| sx={{ width: headCell.width, minWidth: headCell.minWidth }} | |||
| > | |||
| {onSort && headCell.needSort !== false ? ( | |||
| <TableSortLabel | |||
| hideSortIcon | |||
| active={orderBy === headCell.id} | |||
| direction={orderBy === headCell.id ? order : "asc"} | |||
| onClick={() => onSort(headCell.id)} | |||
| sx={{ textTransform: "capitalize" }} | |||
| > | |||
| {headCell.label} | |||
| {orderBy === headCell.id ? ( | |||
| <Box sx={{ ...visuallyHidden }}> | |||
| {order === "desc" | |||
| ? "sorted descending" | |||
| : "sorted ascending"} | |||
| </Box> | |||
| ) : null} | |||
| </TableSortLabel> | |||
| ) : ( | |||
| headCell.label | |||
| )} | |||
| </TableCell> | |||
| ))} | |||
| </TableRow> | |||
| </TableHead> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,61 @@ | |||
| import { | |||
| Button, | |||
| Stack, | |||
| SxProps, | |||
| Table, | |||
| TableBody, | |||
| TableCell, | |||
| TableRow, | |||
| Typography, | |||
| useTheme, | |||
| } from "@mui/material"; | |||
| import TextFieldEx from "components/form/TextFieldEx"; | |||
| import { ReactNode, useEffect, useMemo } from "react"; | |||
| export { default as TableHeadCustom } from "./TableHeadCustom"; | |||
| type SimpleDataListProps = { | |||
| data: { | |||
| title: string; | |||
| value?: string; | |||
| end?: ReactNode; | |||
| }[]; | |||
| tableSx?: SxProps; | |||
| }; | |||
| export const SimpleDataList = ({ data, tableSx }: SimpleDataListProps) => { | |||
| const { typography } = useTheme(); | |||
| const fontSize = useMemo(() => { | |||
| return typography.body2.fontSize; | |||
| }, [typography]); | |||
| return ( | |||
| <Table sx={tableSx}> | |||
| <TableBody> | |||
| {data.map(({ title, value, end }, index) => { | |||
| return ( | |||
| <TableRow key={index}> | |||
| <TableCell | |||
| sx={{ borderRight: "1px solid rgba(224, 224, 224, 1)" }} | |||
| > | |||
| <Typography variant="body2">{title}</Typography> | |||
| </TableCell> | |||
| <TableCell> | |||
| <Stack direction="row" spacing={1}> | |||
| <TextFieldEx | |||
| value={value ?? ""} | |||
| readOnly | |||
| multiline | |||
| fullWidth | |||
| inputProps={{ style: { fontSize } }} | |||
| /> | |||
| {end} | |||
| </Stack> | |||
| </TableCell> | |||
| </TableRow> | |||
| ); | |||
| })} | |||
| </TableBody> | |||
| </Table> | |||
| ); | |||
| }; | |||
| @@ -0,0 +1,13 @@ | |||
| function getEnv() { | |||
| const e = process.env.REACT_APP_ENV; | |||
| if (e === "local") return e; | |||
| if (e === "staging") return e; | |||
| if (e === "production") return e; | |||
| return "other"; | |||
| } | |||
| export const APP_ENV: "local" | "staging" | "production" | "other" = getEnv(); | |||
| // API | |||
| // ---------------------------------------------------------------------- | |||
| export const HOST_API = | |||
| process.env.REACT_APP_HOST_API_KEY || window.location.origin || ""; | |||
| @@ -0,0 +1,165 @@ | |||
| import { HasChildren } from "@types"; | |||
| import { ResultCode } from "api"; | |||
| import { login as APILogin, logout as APILogout, me } from "api/auth"; | |||
| import { UserRole } from "auth/UserRole"; | |||
| import useAPICall from "hooks/useAPICall"; | |||
| import { createContext, memo, useEffect, useMemo, useState } from "react"; | |||
| import { string } from "yup"; | |||
| type ChangedRole = { | |||
| role: UserRole; | |||
| customerCode: string; | |||
| }; | |||
| type Auth = { | |||
| initialized: boolean; | |||
| authenticated: boolean; | |||
| id: string; | |||
| name: string; | |||
| email: string; | |||
| role: UserRole; // デフォルトロール | |||
| currentRole: UserRole; // 現在のロール | |||
| changedRole: ChangedRole | null; | |||
| shopId: number | null; | |||
| login: (email: string, password: string) => Promise<boolean>; | |||
| logout: VoidFunction; | |||
| me: VoidFunction; | |||
| changeRole: (changedRole: ChangedRole | null) => void; | |||
| }; | |||
| export const AuthContext = createContext<Auth>({ | |||
| initialized: false, | |||
| authenticated: false, | |||
| id: "", | |||
| name: "", | |||
| email: "", | |||
| role: UserRole.NONE, | |||
| changedRole: null, | |||
| currentRole: UserRole.NONE, | |||
| shopId: null, | |||
| login: async (email: string, password: string) => false, | |||
| logout: () => {}, | |||
| me: () => {}, | |||
| changeRole: (changedRole: ChangedRole | null) => {}, | |||
| }); | |||
| type Props = HasChildren; | |||
| function AuthContextProvider({ children }: Props) { | |||
| const [initialized, setInitialized] = useState(false); | |||
| const [id, setId] = useState(""); | |||
| const [name, setName] = useState(""); | |||
| const [email, setEmail] = useState(""); | |||
| const [role, setRole] = useState<UserRole>(UserRole.NONE); | |||
| const [changedRole, setChangedRole] = useState<ChangedRole | null>(null); | |||
| const [shopId, setShopId] = useState<number | null>(null); | |||
| const authenticated = useMemo(() => { | |||
| return !!email; | |||
| }, [email]); | |||
| const currentRole = useMemo(() => { | |||
| if (changedRole) { | |||
| return changedRole.role; | |||
| } | |||
| return role; | |||
| }, [changedRole, role]); | |||
| const { callAPI: callMe } = useAPICall({ | |||
| apiMethod: me, | |||
| backDrop: true, | |||
| onSuccess: ({ data }) => { | |||
| console.log("認証有"); | |||
| setInitialized(true); | |||
| setId(data.id); | |||
| setEmail(data.email); | |||
| setName(data.name); | |||
| setRole(data.role); | |||
| }, | |||
| onFailed: () => { | |||
| console.log("認証無"); | |||
| clear(); | |||
| setInitialized(true); | |||
| }, | |||
| }); | |||
| const { callAPI: callLogin } = useAPICall({ | |||
| apiMethod: APILogin, | |||
| backDrop: true, | |||
| onSuccess: () => { | |||
| meFetch(); | |||
| }, | |||
| }); | |||
| const { callAPI: callLogout } = useAPICall({ | |||
| apiMethod: APILogout, | |||
| onSuccess: () => { | |||
| clear(); | |||
| }, | |||
| }); | |||
| const clear = () => { | |||
| setId(""); | |||
| setName(""); | |||
| setEmail(""); | |||
| setRole(UserRole.NONE); | |||
| setChangedRole(null); | |||
| setShopId(null); | |||
| }; | |||
| const login = async (email: string, password: string) => { | |||
| const res = await callLogin({ email, password }); | |||
| return res?.result === ResultCode.SUCCESS; | |||
| }; | |||
| const logout = () => { | |||
| callLogout({}); | |||
| console.info("ログアウト"); | |||
| }; | |||
| const changeRole = (changedRole: ChangedRole | null) => { | |||
| setChangedRole(changedRole); | |||
| }; | |||
| const meFetch = () => { | |||
| callMe({}); | |||
| }; | |||
| useEffect(() => { | |||
| meFetch(); | |||
| }, []); | |||
| return ( | |||
| <AuthContext.Provider | |||
| value={{ | |||
| // Value | |||
| initialized, | |||
| authenticated, | |||
| id, | |||
| name, | |||
| email, | |||
| role, | |||
| currentRole, | |||
| changedRole, | |||
| shopId, | |||
| // Func | |||
| login, | |||
| logout, | |||
| me: meFetch, | |||
| changeRole, | |||
| }} | |||
| > | |||
| {children} | |||
| </AuthContext.Provider> | |||
| ); | |||
| } | |||
| export default memo(AuthContextProvider); | |||
| @@ -0,0 +1,35 @@ | |||
| import { Backdrop, CircularProgress, useTheme } from "@mui/material"; | |||
| import { createContext, useState } from "react"; | |||
| type Props = { | |||
| children: React.ReactNode; | |||
| }; | |||
| type ContextProps = { | |||
| showBackDrop: boolean; | |||
| setShowBackDrop: (show: boolean) => void; | |||
| }; | |||
| const defaultProps: ContextProps = { | |||
| showBackDrop: false, | |||
| setShowBackDrop: (show: boolean) => {}, | |||
| }; | |||
| export const BackDroptContext = createContext<ContextProps>(defaultProps); | |||
| export default function BackDropContextProvider({ children }: Props) { | |||
| const [showBackDrop, setShowBackDrop] = useState(false); | |||
| return ( | |||
| <BackDroptContext.Provider value={{ showBackDrop, setShowBackDrop }}> | |||
| {children} | |||
| <Backdrop | |||
| open={showBackDrop} | |||
| sx={{ | |||
| // display: 'frex', | |||
| zIndex: 9999, | |||
| opacity: "0.3 !important", | |||
| }} | |||
| > | |||
| <CircularProgress color="inherit" /> | |||
| </Backdrop> | |||
| </BackDroptContext.Provider> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,85 @@ | |||
| import { HasChildren } from "@types"; | |||
| import { PageID, TabID } from "pages"; | |||
| import usePage from "hooks/usePage"; | |||
| import useResponsive from "hooks/useResponsive"; | |||
| import useWindowSize from "hooks/useWindowSize"; | |||
| import { ReactNode, createContext, useMemo, useState } from "react"; | |||
| type ContextProps = { | |||
| headerTitle: string; | |||
| setHeaderTitle: (title: string) => void; | |||
| drawerWidth: number; | |||
| innerHeight: number; | |||
| innerWidth: number; | |||
| contentsWidth: number; | |||
| tabs: ReactNode | null; | |||
| setTabs: (tabs: ReactNode | null) => void; | |||
| pageId: PageID; | |||
| setPageId: (pageId: PageID) => void; | |||
| tabId: TabID; | |||
| setTabId: (tabId: TabID) => void; | |||
| showDrawer: boolean; | |||
| overBreakPoint: boolean; | |||
| }; | |||
| const contextInit: ContextProps = { | |||
| headerTitle: "", | |||
| setHeaderTitle: (title: string) => {}, | |||
| drawerWidth: 0, | |||
| innerHeight: 0, | |||
| innerWidth: 0, | |||
| contentsWidth: 0, | |||
| tabs: null, | |||
| setTabs: (tabs: ReactNode | null) => {}, | |||
| pageId: PageID.NONE, | |||
| setPageId: (pageId: PageID) => {}, | |||
| tabId: TabID.NONE, | |||
| setTabId: (tabId: TabID) => {}, | |||
| showDrawer: false, | |||
| overBreakPoint: false, | |||
| }; | |||
| export const DashboardLayoutContext = createContext(contextInit); | |||
| type Props = HasChildren; | |||
| export function DashboardLayoutContextProvider({ children }: Props) { | |||
| const drawerWidth = 256; | |||
| const [headerTitle, setHeaderTitle] = useState(""); | |||
| const [tabs, setTabs] = useState<ReactNode | null>(null); | |||
| const { width: innerWidth, height: innerHeight } = useWindowSize(); | |||
| const { pageId, setPageId, tabId, setTabId } = usePage(); | |||
| const overBreakPoint = !!useResponsive("up", "sm"); | |||
| const showDrawer = useMemo(() => overBreakPoint, [overBreakPoint]); | |||
| const contentsWidth = useMemo(() => { | |||
| return innerWidth - (showDrawer ? drawerWidth : 0); | |||
| }, [drawerWidth, innerWidth, showDrawer]); | |||
| return ( | |||
| <DashboardLayoutContext.Provider | |||
| value={{ | |||
| headerTitle, | |||
| setHeaderTitle, | |||
| drawerWidth, | |||
| innerWidth, | |||
| innerHeight, | |||
| contentsWidth, | |||
| tabs, | |||
| setTabs, | |||
| pageId, | |||
| setPageId, | |||
| tabId, | |||
| setTabId, | |||
| showDrawer, | |||
| overBreakPoint, | |||
| }} | |||
| > | |||
| {children} | |||
| </DashboardLayoutContext.Provider> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,58 @@ | |||
| import { Dialog, DialogActions, DialogContent, Button } from "@mui/material"; | |||
| import { HasChildren } from "@types"; | |||
| import { PageID, TabID } from "pages"; | |||
| import { createContext, useState } from "react"; | |||
| type ContextProps = { | |||
| pageId: PageID; | |||
| tabId: TabID; | |||
| setPageId: (pageId: PageID) => void; | |||
| setTabId: (tabId: TabID) => void; | |||
| openDialog: (message: string) => void; | |||
| }; | |||
| const contextInit: ContextProps = { | |||
| pageId: PageID.NONE, | |||
| tabId: TabID.NONE, | |||
| setPageId: (pageId: PageID) => {}, | |||
| setTabId: (tabId: TabID) => {}, | |||
| openDialog: (message: string) => {}, | |||
| }; | |||
| export const PageContext = createContext(contextInit); | |||
| type Props = HasChildren; | |||
| export function PageContextProvider({ children }: Props) { | |||
| const [pageId, setPageId] = useState<PageID>(PageID.NONE); | |||
| const [tabId, setTabId] = useState<TabID>(TabID.NONE); | |||
| const [open, setOpen] = useState(false); | |||
| const [dialogMessage, setDialogMessage] = useState(""); | |||
| const openDialog = (message: string) => { | |||
| setOpen(true); | |||
| setDialogMessage(message); | |||
| }; | |||
| const close = () => { | |||
| setOpen(false); | |||
| }; | |||
| return ( | |||
| <PageContext.Provider | |||
| value={{ | |||
| pageId, | |||
| tabId, | |||
| setPageId, | |||
| setTabId, | |||
| openDialog, | |||
| }} | |||
| > | |||
| {children} | |||
| <Dialog open={open} onClose={close}> | |||
| <DialogContent>{dialogMessage}</DialogContent> | |||
| <DialogActions> | |||
| <Button onClick={close}>閉じる</Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| </PageContext.Provider> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,135 @@ | |||
| import { cloneDeep, isEqual, unset } from "lodash"; | |||
| import { ReactNode, createContext, useEffect, useMemo, useState } from "react"; | |||
| // form | |||
| import { useLocation } from "react-router"; | |||
| import { Dictionary } from "@types"; | |||
| import useNavigateCustom from "hooks/useNavigateCustom"; | |||
| // ---------------------------------------------------------------------- | |||
| const _condition: Dictionary = {}; | |||
| const initialState = { | |||
| initialized: false, | |||
| condition: _condition, | |||
| get: (key: string, defaultValue?: string): string => "", | |||
| initializeCondition: () => {}, | |||
| addCondition: (condition: Dictionary) => { | |||
| console.log("not init SearchConditionContext"); | |||
| }, | |||
| clearCondition: () => {}, | |||
| }; | |||
| export const SearchConditionContext = createContext(initialState); | |||
| type Props = { | |||
| children: ReactNode; | |||
| }; | |||
| export function SearchConditionContextProvider({ children }: Props) { | |||
| const [condition, _setCondition] = useState<Dictionary>({}); | |||
| const [initialized, setInitialized] = useState(false); | |||
| const { pathname, search } = useLocation(); | |||
| const { navigateWhenChanged } = useNavigateCustom(); | |||
| const setCondition = (after: Dictionary, message?: any) => { | |||
| if (message) { | |||
| console.log("Contidion Change", { after, message }); | |||
| } | |||
| _setCondition(after); | |||
| }; | |||
| const initializeCondition = () => { | |||
| const after: Dictionary = {}; | |||
| const urlParam = new URLSearchParams(search); | |||
| for (const [key, value] of urlParam.entries()) { | |||
| after[key] = value; | |||
| } | |||
| if (!isEqual(after, condition)) { | |||
| setCondition(after, "initializeCondition"); | |||
| } | |||
| setInitialized(true); | |||
| }; | |||
| const get = (key: string, defaultValue?: string) => { | |||
| return condition[key] ?? defaultValue ?? ""; | |||
| }; | |||
| const getCondition = useMemo(() => { | |||
| return cloneDeep(condition); | |||
| }, [condition]); | |||
| const addCondition = (additional: Dictionary) => { | |||
| if (!initialized) return; | |||
| const before = cloneDeep(condition); | |||
| const after = cloneDeep(condition); | |||
| Object.keys(additional).forEach((key) => { | |||
| unset(after, key); | |||
| if (additional[key] !== "") { | |||
| after[key] = additional[key]; | |||
| } | |||
| }); | |||
| // console.log("compare condition", { | |||
| // before, | |||
| // after, | |||
| // }); | |||
| if (!isEqual(before, after)) { | |||
| // console.log("add condition", { | |||
| // before, | |||
| // after, | |||
| // }); | |||
| setCondition(after, "addCondition"); | |||
| } | |||
| }; | |||
| const searchParams = useMemo(() => { | |||
| const params = new URLSearchParams(); | |||
| if (!initialized) return params; | |||
| Object.keys(condition).forEach((key) => { | |||
| params.append(key, condition[key]); | |||
| }); | |||
| params.sort(); | |||
| return params; | |||
| }, [condition]); | |||
| const applyToURL = () => { | |||
| if (!initialized) return; | |||
| navigateWhenChanged(pathname, condition, { context: "applyToURL" }); | |||
| }; | |||
| const clearCondition = () => { | |||
| setCondition({}, "clearCondition"); | |||
| setInitialized(false); | |||
| }; | |||
| useEffect(() => { | |||
| if (initialized) { | |||
| // console.log("call applyToURL", { condition, initialized }); | |||
| applyToURL(); | |||
| } | |||
| }, [condition, initialized]); | |||
| useEffect(() => { | |||
| initializeCondition(); | |||
| }, [pathname, search]); | |||
| return ( | |||
| <SearchConditionContext.Provider | |||
| value={{ | |||
| condition: getCondition, | |||
| initialized, | |||
| get, | |||
| initializeCondition, | |||
| addCondition, | |||
| clearCondition, | |||
| }} | |||
| > | |||
| {children} | |||
| </SearchConditionContext.Provider> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,31 @@ | |||
| import { HasChildren } from "@types"; | |||
| import { createContext, useLayoutEffect, useState } from "react"; | |||
| export const WindowSizeContext = createContext({ | |||
| width: 0, | |||
| height: 0, | |||
| }); | |||
| type Props = HasChildren; | |||
| export function WindowSizeContextProvider({ children }: Props) { | |||
| const [width, setWidth] = useState(window.innerWidth); | |||
| const [height, setHeight] = useState(window.innerHeight); | |||
| useLayoutEffect(() => { | |||
| const updateSize = () => { | |||
| setWidth(window.innerWidth); | |||
| setHeight(window.innerHeight); | |||
| }; | |||
| window.addEventListener("resize", updateSize); | |||
| updateSize(); | |||
| return () => window.removeEventListener("resize", updateSize); | |||
| }, []); | |||
| return ( | |||
| <WindowSizeContext.Provider value={{ width, height }}> | |||
| {children} | |||
| </WindowSizeContext.Provider> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,33 @@ | |||
| import { HasChildren } from "@types"; | |||
| import useAuth from "hooks/useAuth"; | |||
| import Login from "pages/auth/login"; | |||
| import { useState } from "react"; | |||
| import { Navigate, useLocation } from "react-router-dom"; | |||
| export default function AuthGuard({ children }: HasChildren) { | |||
| const { authenticated, initialized } = useAuth(); | |||
| const { pathname } = useLocation(); | |||
| const [requestedLocation, setRequestedLocation] = useState<string | null>( | |||
| null | |||
| ); | |||
| if (!initialized) { | |||
| return null; | |||
| } | |||
| if (!authenticated) { | |||
| if (pathname !== requestedLocation) { | |||
| setRequestedLocation(pathname); | |||
| } | |||
| return <Login />; | |||
| } | |||
| if (requestedLocation && pathname !== requestedLocation) { | |||
| setRequestedLocation(null); | |||
| return <Navigate to={requestedLocation} />; | |||
| } | |||
| return <>{children}</>; | |||
| } | |||
| @@ -0,0 +1,186 @@ | |||
| import { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { APICommonResponse, apiRequest, ResultCode } from "api"; | |||
| import { UseFormReturn } from "react-hook-form"; | |||
| import { Dictionary } from "@types"; | |||
| import { useSnackbar } from "notistack"; | |||
| import usePage from "./usePage"; | |||
| import useBackDrop from "./useBackDrop"; | |||
| export const APIErrorType = { | |||
| NONE: "none", | |||
| INPUT: "input", | |||
| SERVER: "server", | |||
| EXCLUSIVE: "exclusive", | |||
| } as const; | |||
| export type APIErrorType = (typeof APIErrorType)[keyof typeof APIErrorType]; | |||
| export default function useAPICall< | |||
| T extends object, | |||
| U extends APICommonResponse | |||
| >({ | |||
| apiMethod, | |||
| onSuccess, | |||
| onFailed, | |||
| form, | |||
| successMessage = false, | |||
| failedMessage = false, | |||
| backDrop = false, | |||
| }: { | |||
| apiMethod: (sendData: T) => Promise<U | null>; | |||
| onSuccess?: (res: U, sendData: T) => void; | |||
| onFailed?: (res: APICommonResponse | null) => void; | |||
| form?: UseFormReturn<any>; | |||
| successMessage?: ((res: U) => string) | boolean; | |||
| failedMessage?: ((res: APICommonResponse | null) => string) | boolean; | |||
| backDrop?: boolean; | |||
| }) { | |||
| const [sending, setSending] = useState(false); | |||
| const [sendData, setSendData] = useState<T | null>(null); | |||
| const [result, setResult] = useState<ResultCode | null>(null); | |||
| const [errors, setErrors] = useState<Dictionary>({}); | |||
| const [validationError, setValidationError] = useState(false); | |||
| const [exclusiveError, setExclusiveError] = useState(false); | |||
| const [generalErrorMessage, setGeneralErrorMessage] = useState(""); | |||
| const { openDialog } = usePage(); | |||
| const { enqueueSnackbar } = useSnackbar(); | |||
| const { setShowBackDrop } = useBackDrop(); | |||
| const clearErrors = () => { | |||
| setResult(null); | |||
| setErrors({}); | |||
| setSendData(null); | |||
| setValidationError(false); | |||
| setExclusiveError(false); | |||
| setGeneralErrorMessage(""); | |||
| }; | |||
| // 入力項目に対してエラーレスポンスがあるか、ないかでエラーのタイプを設定する | |||
| const errorMode = useMemo<APIErrorType>(() => { | |||
| if (generalErrorMessage !== "") return APIErrorType.INPUT; | |||
| if (exclusiveError) return APIErrorType.EXCLUSIVE; | |||
| if (validationError) return APIErrorType.INPUT; | |||
| if (result === null) return APIErrorType.NONE; | |||
| if (result === ResultCode.SUCCESS) return APIErrorType.NONE; | |||
| if (sendData === null) return APIErrorType.NONE; | |||
| const messageKeys = Object.keys(errors); | |||
| if (messageKeys.length === 0) return APIErrorType.SERVER; | |||
| if (sendData) { | |||
| const sendDataKeys = Object.keys(sendData); | |||
| const find = messageKeys.find((key: string) => { | |||
| return sendDataKeys.includes(key); | |||
| }); | |||
| if (find) { | |||
| return APIErrorType.INPUT; | |||
| } | |||
| } | |||
| return APIErrorType.SERVER; | |||
| }, [result, errors, validationError, exclusiveError, generalErrorMessage]); | |||
| const getSuccessMessageStr = useCallback( | |||
| (res: U) => { | |||
| if (typeof successMessage === "boolean") { | |||
| return successMessage ? "成功しました" : ""; | |||
| } | |||
| return successMessage(res); | |||
| }, | |||
| [successMessage] | |||
| ); | |||
| const getFailedMessageStr = useCallback( | |||
| (res: APICommonResponse | null) => { | |||
| if (typeof failedMessage === "boolean") { | |||
| return failedMessage ? "失敗しました" : ""; | |||
| } | |||
| return failedMessage(res); | |||
| }, | |||
| [failedMessage] | |||
| ); | |||
| const successCallback = (res: U, sendData: T) => { | |||
| setResult(ResultCode.SUCCESS); | |||
| const message = getSuccessMessageStr(res); | |||
| if (message) { | |||
| enqueueSnackbar(message); | |||
| } | |||
| if (onSuccess) { | |||
| onSuccess(res, sendData); | |||
| } | |||
| }; | |||
| const failedCallback = (res: APICommonResponse | null) => { | |||
| if (res?.result === ResultCode.EXCLUSIVE_ERROR) { | |||
| setExclusiveError(true); | |||
| } | |||
| if (res) { | |||
| setResult(res.result); | |||
| } | |||
| if (res?.messages.errors) { | |||
| console.log("seterrors", res.messages.errors); | |||
| setErrors(res.messages.errors); | |||
| } | |||
| if (res?.messages.general) { | |||
| setGeneralErrorMessage(res.messages.general); | |||
| } | |||
| const message = getFailedMessageStr(res); | |||
| if (message) { | |||
| enqueueSnackbar(message, { variant: "error" }); | |||
| } | |||
| if (onFailed) { | |||
| onFailed(res); | |||
| } | |||
| if (res?.result === ResultCode.EXCLUSIVE_ERROR) { | |||
| openDialog("恐れ入りますが、ページを再読込後、再実行してください"); | |||
| } | |||
| }; | |||
| const handleValidationError = (error: any) => { | |||
| console.log(error); | |||
| setValidationError(true); | |||
| }; | |||
| const callAPI = async (sendData: T) => { | |||
| if (form) { | |||
| form.clearErrors(); | |||
| } | |||
| clearErrors(); | |||
| setSendData(sendData); | |||
| const res = await apiRequest({ | |||
| apiMethod, | |||
| onSuccess: successCallback, | |||
| onFailed: failedCallback, | |||
| sendData, | |||
| setSending, | |||
| errorSetter: form?.setError, | |||
| }); | |||
| return res; | |||
| }; | |||
| const makeSendData = (data: T) => { | |||
| return data; | |||
| }; | |||
| useEffect(() => { | |||
| if (backDrop) { | |||
| setShowBackDrop(sending); | |||
| } | |||
| }, [sending]); | |||
| return { | |||
| callAPI, | |||
| sending, | |||
| errorMode, | |||
| generalErrorMessage, | |||
| handleValidationError, | |||
| makeSendData, | |||
| }; | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| import { AuthContext } from "contexts/AuthContext"; | |||
| import { useContext } from "react"; | |||
| export default function useAuth() { | |||
| const context = useContext(AuthContext); | |||
| return context; | |||
| } | |||
| @@ -0,0 +1,6 @@ | |||
| import { useContext } from "react"; | |||
| import { BackDroptContext } from "contexts/BackDropContext"; | |||
| export default function useBackDrop() { | |||
| return useContext(BackDroptContext); | |||
| } | |||
| @@ -0,0 +1,18 @@ | |||
| import { useContext, useEffect } from "react"; | |||
| import { DashboardLayoutContext } from "contexts/DashboardLayoutContext"; | |||
| import { PageID, TabID } from "pages"; | |||
| export default function useDashboard(pageId?: PageID, tabId?: TabID) { | |||
| const context = useContext(DashboardLayoutContext); | |||
| useEffect(() => { | |||
| if (pageId) { | |||
| context.setPageId(pageId); | |||
| } | |||
| if (tabId) { | |||
| context.setTabId(tabId); | |||
| } | |||
| }, []); | |||
| return context; | |||
| } | |||
| @@ -0,0 +1,86 @@ | |||
| import { | |||
| Button, | |||
| Dialog, | |||
| DialogActions, | |||
| DialogContent, | |||
| DialogContentText, | |||
| DialogTitle, | |||
| } from "@mui/material"; | |||
| import { ReactNode, useState } from "react"; | |||
| type Props = { | |||
| message?: string; | |||
| onClose?: VoidFunction; | |||
| onAgree?: VoidFunction; | |||
| onDisagree?: VoidFunction; | |||
| }; | |||
| export type UseDialogReturn = { | |||
| show: boolean; | |||
| open: VoidFunction; | |||
| close: VoidFunction; | |||
| setShow: (show: boolean) => void; | |||
| element: ReactNode; | |||
| }; | |||
| export function useDialog({ | |||
| message, | |||
| onClose, | |||
| onAgree, | |||
| onDisagree, | |||
| }: Props = {}): UseDialogReturn { | |||
| const [show, setShow] = useState(false); | |||
| const open = () => { | |||
| setShow(true); | |||
| }; | |||
| const close = () => { | |||
| if (onClose) { | |||
| onClose(); | |||
| } | |||
| setShow(false); | |||
| }; | |||
| const agree = () => { | |||
| if (onAgree) { | |||
| onAgree(); | |||
| } | |||
| setShow(false); | |||
| }; | |||
| const disagree = () => { | |||
| if (onDisagree) { | |||
| onDisagree(); | |||
| } | |||
| setShow(false); | |||
| }; | |||
| const element = ( | |||
| <Dialog open={show} onClose={close}> | |||
| <DialogTitle>確認</DialogTitle> | |||
| {message && ( | |||
| <DialogContent> | |||
| <DialogContentText>{message}</DialogContentText> | |||
| </DialogContent> | |||
| )} | |||
| <DialogActions> | |||
| <Button onClick={disagree}>CANCEL</Button> | |||
| <Button onClick={agree}>OK</Button> | |||
| </DialogActions> | |||
| </Dialog> | |||
| ); | |||
| return { | |||
| // param | |||
| show, | |||
| // Element | |||
| element, | |||
| // function | |||
| open, | |||
| close, | |||
| setShow, | |||
| }; | |||
| } | |||
| @@ -0,0 +1,62 @@ | |||
| import { useLocation, useNavigate } from "react-router"; | |||
| import { Dictionary } from "@types"; | |||
| import { PageID } from "pages"; | |||
| import { getPath } from "routes/path"; | |||
| import { scrollToTop } from "utils/page"; | |||
| export default function useNavigateCustom() { | |||
| const navigate = useNavigate(); | |||
| const { pathname, search } = useLocation(); | |||
| const navigateWhenChanged = ( | |||
| path: string, | |||
| param?: Dictionary | URLSearchParams | string, | |||
| option?: { | |||
| reload?: boolean; | |||
| context?: any; | |||
| } | |||
| ) => { | |||
| const currentUrl = pathname + search; | |||
| let newPath = path; | |||
| if (typeof param === "string") { | |||
| if (!param.startsWith("?")) { | |||
| newPath += "?"; | |||
| } | |||
| newPath += param; | |||
| } else if (param instanceof URLSearchParams) { | |||
| const search = param.toString(); | |||
| if (search) { | |||
| newPath += "?"; | |||
| newPath += search; | |||
| } | |||
| } else if (typeof param === "object") { | |||
| const urlParam = new URLSearchParams(param); | |||
| const search = urlParam.toString(); | |||
| if (search) { | |||
| newPath += "?"; | |||
| newPath += search; | |||
| } | |||
| } | |||
| if (currentUrl !== newPath || option?.reload) { | |||
| if (option?.context) { | |||
| console.log("navigate to", newPath, option.context); | |||
| } | |||
| // 同じURLで遷移要求があった場合、reload設定されていれば同じページを読み込みなおす | |||
| // 一旦、空白のページを経由する必要がある | |||
| if (currentUrl === newPath && option?.reload) { | |||
| navigate(getPath(PageID.NONE)); | |||
| setTimeout(() => { | |||
| navigate(newPath); | |||
| }, 50); | |||
| } else { | |||
| navigate(newPath); | |||
| } | |||
| } | |||
| scrollToTop("auto"); | |||
| }; | |||
| return { navigate, navigateWhenChanged }; | |||
| } | |||
| @@ -0,0 +1,6 @@ | |||
| import { PageContext } from "contexts/PageContext"; | |||
| import { useContext } from "react"; | |||
| export default function usePage() { | |||
| return useContext(PageContext); | |||
| } | |||
| @@ -0,0 +1,39 @@ | |||
| // @mui | |||
| import { Breakpoint } from '@mui/material'; | |||
| import { useTheme } from '@mui/material/styles'; | |||
| import useMediaQuery from '@mui/material/useMediaQuery'; | |||
| // ---------------------------------------------------------------------- | |||
| type Query = 'up' | 'down' | 'between' | 'only'; | |||
| type Key = Breakpoint | number; | |||
| type Start = Breakpoint | number; | |||
| type End = Breakpoint | number; | |||
| export default function useResponsive(query: Query, key?: Key, start?: Start, end?: End) { | |||
| const theme = useTheme(); | |||
| const mediaUp = useMediaQuery(theme.breakpoints.up(key as Key)); | |||
| const mediaDown = useMediaQuery(theme.breakpoints.down(key as Key)); | |||
| const mediaBetween = useMediaQuery(theme.breakpoints.between(start as Start, end as End)); | |||
| const mediaOnly = useMediaQuery(theme.breakpoints.only(key as Breakpoint)); | |||
| if (query === 'up') { | |||
| return mediaUp; | |||
| } | |||
| if (query === 'down') { | |||
| return mediaDown; | |||
| } | |||
| if (query === 'between') { | |||
| return mediaBetween; | |||
| } | |||
| if (query === 'only') { | |||
| return mediaOnly; | |||
| } | |||
| } | |||
| @@ -0,0 +1,6 @@ | |||
| import { useContext } from "react"; | |||
| import { SearchConditionContext } from "contexts/SearchConditionContext"; | |||
| export default function useSearchConditionContext() { | |||
| return useContext(SearchConditionContext); | |||
| } | |||
| @@ -0,0 +1,26 @@ | |||
| import { OptionsObject, useSnackbar } from "notistack"; | |||
| export default function useSnackbarCustom() { | |||
| const { enqueueSnackbar } = useSnackbar(); | |||
| const info = (message: string, option?: OptionsObject) => { | |||
| enqueueSnackbar(message, { variant: "info", ...option }); | |||
| }; | |||
| const success = (message: string, option?: OptionsObject) => { | |||
| enqueueSnackbar(message, { variant: "success", ...option }); | |||
| }; | |||
| const warn = (message: string, option?: OptionsObject) => { | |||
| enqueueSnackbar(message, { variant: "warning", ...option }); | |||
| }; | |||
| const error = (message: string, option?: OptionsObject) => { | |||
| enqueueSnackbar(message, { variant: "error", ...option }); | |||
| }; | |||
| return { | |||
| enqueueSnackbar, | |||
| info, | |||
| success, | |||
| warn, | |||
| error, | |||
| }; | |||
| } | |||
| @@ -0,0 +1,179 @@ | |||
| import { ceil, max } from "lodash"; | |||
| import { useEffect, useMemo, useState } from "react"; | |||
| import { useParams } from "react-router"; | |||
| import useNavigateCustom from "./useNavigateCustom"; | |||
| import useSearchConditionContext from "./useSearchConditionContext"; | |||
| import useURLSearchParams from "./useURLSearchParams"; | |||
| import usePage from "./usePage"; | |||
| import { getListPagePath } from "routes/path"; | |||
| // ---------------------------------------------------------------------- | |||
| export type UseTableReturn<T extends object> = { | |||
| order: "asc" | "desc"; | |||
| page: number; | |||
| sort: string; | |||
| rowsPerPage: number; | |||
| fetched: boolean; | |||
| row: T[]; | |||
| fillteredRow: T[]; | |||
| isNotFound: boolean; | |||
| dataLength: number; | |||
| // | |||
| onSort: (id: string) => void; | |||
| onChangePage: (event: unknown, page: number) => void; | |||
| onChangeRowsPerPage: (event: React.ChangeEvent<HTMLInputElement>) => void; | |||
| // | |||
| setRowData: (data: T[]) => void; | |||
| // | |||
| ROWS_PER_PAGES: number[]; | |||
| }; | |||
| export default function useTable<T extends object>(): UseTableReturn<T> { | |||
| const ROWS_PER_PAGES = [50, 100, 500]; | |||
| const ORDER = "order"; | |||
| const SORT = "sort"; | |||
| const ROWS = "rows"; | |||
| const { pageId } = usePage(); | |||
| const { page: urlPage } = useParams(); | |||
| const { navigateWhenChanged } = useNavigateCustom(); | |||
| const { addCondition, initialized, get } = useSearchConditionContext(); | |||
| const { search } = useURLSearchParams(); | |||
| const [sort, setSort] = useState(""); | |||
| const [order, setOrder] = useState<"asc" | "desc">("asc"); | |||
| const [rowsPerPage, setRowsPerPage] = useState(ROWS_PER_PAGES[0]); | |||
| const [page, setPage] = useState(0); | |||
| const [data, setData] = useState<T[]>([]); | |||
| const [fetched, setFetched] = useState(false); | |||
| const [requestPage, setRequestPage] = useState<number | null>(null); | |||
| const paging = (page: number) => { | |||
| return getListPagePath(pageId, page); | |||
| }; | |||
| // 表示する行 | |||
| const fillteredRow = useMemo(() => { | |||
| if (requestPage !== page) { | |||
| // console.log("fillteredRow DIFFs", { requestPage, page }); | |||
| return []; | |||
| } | |||
| return data.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage); | |||
| }, [page, requestPage, rowsPerPage, data]); | |||
| const isNotFound = useMemo(() => { | |||
| if (data.length !== 0) return false; | |||
| if (!fetched) return false; | |||
| return true; | |||
| }, [fetched, data]); | |||
| const dataLength = useMemo(() => { | |||
| return data.length; | |||
| }, [data]); | |||
| const maxPage = useMemo(() => { | |||
| return max([ceil(dataLength / rowsPerPage) - 1, 0]) ?? 0; | |||
| }, [dataLength, rowsPerPage]); | |||
| const onSort = (id: string) => { | |||
| const isAsc = sort === id && order === "asc"; | |||
| if (id !== "") { | |||
| const tmpOrder = isAsc ? "desc" : "asc"; | |||
| setOrder(tmpOrder); | |||
| setSort(id); | |||
| addCondition({ | |||
| [SORT]: id, | |||
| [ORDER]: tmpOrder, | |||
| }); | |||
| } | |||
| }; | |||
| const onChangePage = (event: unknown, page: number) => { | |||
| setRequestPage(page); | |||
| }; | |||
| const onChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => { | |||
| const newRowsPerPage = parseInt(event.target.value, 10); | |||
| setRowsPerPage(newRowsPerPage); | |||
| setPage(0); | |||
| addCondition({ | |||
| rows: String(newRowsPerPage), | |||
| }); | |||
| }; | |||
| // 一度ページを0にする | |||
| // 下記のページ遷移制御によって、requestPageのと調整を行う | |||
| const setRowData = (data: T[]) => { | |||
| setPage(0); | |||
| setData(data); | |||
| setFetched(true); | |||
| }; | |||
| // 主にページ遷移を制御する | |||
| // リストデータ数や1ページあたりの表示数によっては、現在のページが最大ページを超えてしまう可能性があるので | |||
| // 一度、requestPageにページ番号をためておき、最大ページを超えないように調整する | |||
| useEffect(() => { | |||
| if (requestPage !== null && fetched) { | |||
| const newPage = requestPage <= maxPage ? requestPage : maxPage; | |||
| if (page !== newPage) { | |||
| setPage(newPage); | |||
| } | |||
| if (requestPage !== newPage) { | |||
| setRequestPage(newPage); | |||
| } | |||
| navigateWhenChanged(paging(newPage), search, { | |||
| context: "useTable.paging", | |||
| }); | |||
| } | |||
| }, [requestPage, maxPage, fetched, page]); | |||
| // クエリパラメータから各初期値を設定する | |||
| useEffect(() => { | |||
| if (initialized) { | |||
| setSort(get(SORT)); | |||
| const order = get(ORDER); | |||
| if (order === "asc" || order === "desc") { | |||
| setOrder(order); | |||
| } | |||
| const rows = Number(get(ROWS)); | |||
| setRowsPerPage(ROWS_PER_PAGES.includes(rows) ? rows : ROWS_PER_PAGES[0]); | |||
| } | |||
| }, [initialized]); | |||
| useEffect(() => { | |||
| setRequestPage(Number(urlPage ?? "0")); | |||
| }, [urlPage]); | |||
| return { | |||
| order, | |||
| page, | |||
| sort, | |||
| rowsPerPage, | |||
| fetched, | |||
| row: data, | |||
| fillteredRow, | |||
| isNotFound, | |||
| dataLength, | |||
| // | |||
| onSort, | |||
| onChangePage, | |||
| onChangeRowsPerPage, | |||
| // | |||
| setRowData, | |||
| // | |||
| ROWS_PER_PAGES, | |||
| }; | |||
| } | |||
| @@ -0,0 +1,48 @@ | |||
| import { useEffect, useState } from 'react'; | |||
| import { useLocation, useNavigate } from 'react-router'; | |||
| export default function useURLSearchParams() { | |||
| const navigate = useNavigate(); | |||
| const { pathname, search } = useLocation(); | |||
| const [urlParam, setUrlParam] = useState(new URLSearchParams(search)); | |||
| const [needApply, setNeedApply] = useState(false); | |||
| const setParam = () => { | |||
| const path = pathname; | |||
| const url = path + '?' + urlParam.toString(); | |||
| const current = pathname + search; | |||
| if (url !== current) { | |||
| navigate(url); | |||
| } | |||
| setNeedApply(false); | |||
| }; | |||
| const appendAll = (list: { key: string; value: string | null | undefined }[]) => { | |||
| list.forEach(({ key, value }) => { | |||
| urlParam.delete(key); | |||
| if (value) { | |||
| urlParam.append(key, value); | |||
| } | |||
| }); | |||
| urlParam.sort(); | |||
| setNeedApply(true); | |||
| }; | |||
| useEffect(() => { | |||
| setUrlParam(new URLSearchParams(search)); | |||
| }, [search]); | |||
| useEffect(() => { | |||
| if (needApply) { | |||
| setParam(); | |||
| } | |||
| }, [needApply]); | |||
| return { | |||
| search, | |||
| urlParam, | |||
| appendAll, | |||
| }; | |||
| } | |||
| @@ -0,0 +1,98 @@ | |||
| import { Box, Button, Stack, Typography, styled } from "@mui/material"; | |||
| import { useRef, useState } from "react"; | |||
| import { Controller, useFormContext } from "react-hook-form"; | |||
| interface Props {} | |||
| export function useUpload(name: string, {}: Props = {}) { | |||
| const fileInput = useRef<HTMLInputElement | null>(null); | |||
| const [fileName, setFileName] = useState(""); | |||
| const [imageData, setImageData] = useState(""); | |||
| const { register } = useFormContext(); | |||
| const onChange = (e: React.ChangeEvent<HTMLInputElement>) => { | |||
| const files = e.target.files; | |||
| if (!files || files.length <= 0) return; | |||
| deployment(files); | |||
| }; | |||
| const { ref, ...rest } = register(name, { | |||
| onChange, | |||
| }); | |||
| const selectFile = () => { | |||
| if (!fileInput.current) return; | |||
| fileInput.current.removeAttribute("capture"); | |||
| fileInput.current.click(); | |||
| }; | |||
| // ファイルを選択した時の処理 | |||
| const deployment = (files: FileList) => { | |||
| const file = files[0]; | |||
| const fileReader = new FileReader(); | |||
| setFileName(file.name); | |||
| fileReader.onload = () => { | |||
| setImageData(fileReader.result as string); | |||
| }; | |||
| fileReader.readAsDataURL(file); | |||
| }; | |||
| const reset = () => { | |||
| setFileName(""); | |||
| setImageData(""); | |||
| if (fileInput.current) { | |||
| fileInput.current.value = ""; | |||
| } | |||
| }; | |||
| const uploadButton = ( | |||
| <div> | |||
| <input | |||
| type="file" | |||
| id="file" | |||
| ref={(e) => { | |||
| ref(e); | |||
| fileInput.current = e; | |||
| }} | |||
| accept="image/*" | |||
| style={{ display: "none" }} | |||
| {...rest} | |||
| /> | |||
| <button onClick={selectFile} type="button"> | |||
| 📁 ファイルから選択 | |||
| </button> | |||
| </div> | |||
| ); | |||
| const view = ( | |||
| <div> | |||
| {fileName && ( | |||
| <> | |||
| <Box position="relative"> | |||
| <img src={imageData} style={{ margin: "auto", maxWidth: "100%" }} /> | |||
| <button | |||
| onClick={reset} | |||
| style={{ | |||
| position: "absolute", | |||
| top: 0, | |||
| right: 0, | |||
| }} | |||
| > | |||
| ❌ | |||
| </button> | |||
| <div>{fileName}</div> | |||
| </Box> | |||
| </> | |||
| )} | |||
| </div> | |||
| ); | |||
| return { | |||
| // element | |||
| uploadButton, | |||
| view, | |||
| // hook | |||
| fileName, | |||
| imageData, | |||
| }; | |||
| } | |||
| @@ -0,0 +1,6 @@ | |||
| import { useContext } from "react"; | |||
| import { WindowSizeContext } from "contexts/WindowSizeContext"; | |||
| export default function useWindowSize() { | |||
| return useContext(WindowSizeContext); | |||
| } | |||
| @@ -0,0 +1,13 @@ | |||
| body { | |||
| margin: 0; | |||
| font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', | |||
| 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', | |||
| sans-serif; | |||
| -webkit-font-smoothing: antialiased; | |||
| -moz-osx-font-smoothing: grayscale; | |||
| } | |||
| code { | |||
| font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', | |||
| monospace; | |||
| } | |||
| @@ -0,0 +1,21 @@ | |||
| import ReactDOM from "react-dom/client"; | |||
| import App from "./App"; | |||
| import "./index.css"; | |||
| import reportWebVitals from "./reportWebVitals"; | |||
| // コンソールログ出力の抑制 | |||
| // process.env.NODE_ENV !== "development" && (console.log = () => {}); | |||
| const root = ReactDOM.createRoot( | |||
| document.getElementById("root") as HTMLElement | |||
| ); | |||
| root.render( | |||
| // <React.StrictMode> | |||
| <App /> | |||
| // </React.StrictMode> | |||
| ); | |||
| // If you want to start measuring performance in your app, pass a function | |||
| // to log results (for example: reportWebVitals(console.log)) | |||
| // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals | |||
| reportWebVitals(); | |||
| @@ -0,0 +1,112 @@ | |||
| import MenuIcon from "@mui/icons-material/Menu"; | |||
| import { Box } from "@mui/material"; | |||
| import AppBar from "@mui/material/AppBar"; | |||
| import Grid from "@mui/material/Grid"; | |||
| import IconButton from "@mui/material/IconButton"; | |||
| import Toolbar from "@mui/material/Toolbar"; | |||
| import Typography from "@mui/material/Typography"; | |||
| import useDashboard from "hooks/useDashBoard"; | |||
| import * as React from "react"; | |||
| interface HeaderProps { | |||
| onDrawerToggle: () => void; | |||
| } | |||
| export default function Header(props: HeaderProps) { | |||
| const { onDrawerToggle } = props; | |||
| const { contentsWidth, headerTitle, tabs } = useDashboard(); | |||
| return ( | |||
| <React.Fragment> | |||
| <AppBar color="primary" position="sticky" elevation={0}> | |||
| <Toolbar> | |||
| <Grid container spacing={1} alignItems="center"> | |||
| <Grid sx={{ display: { sm: "none", xs: "block" } }} item> | |||
| <IconButton | |||
| color="inherit" | |||
| aria-label="open drawer" | |||
| onClick={onDrawerToggle} | |||
| edge="start" | |||
| > | |||
| <MenuIcon /> | |||
| </IconButton> | |||
| </Grid> | |||
| {/* <Grid item xs /> */} | |||
| {/* <Grid item> | |||
| <Link | |||
| href="/" | |||
| variant="body2" | |||
| sx={{ | |||
| textDecoration: "none", | |||
| color: lightColor, | |||
| "&:hover": { | |||
| color: "common.white", | |||
| }, | |||
| }} | |||
| rel="noopener noreferrer" | |||
| target="_blank" | |||
| > | |||
| Go to docs | |||
| </Link> | |||
| </Grid> */} | |||
| {/* <Grid item> | |||
| <Tooltip title="Alerts • No alerts"> | |||
| <IconButton color="inherit"> | |||
| <NotificationsIcon /> | |||
| </IconButton> | |||
| </Tooltip> | |||
| </Grid> */} | |||
| {/* <Grid item> | |||
| <IconButton color="inherit" sx={{ p: 0.5 }}> | |||
| <Avatar src="/static/images/avatar/1.jpg" alt="My Avatar" /> | |||
| </IconButton> | |||
| </Grid> */} | |||
| </Grid> | |||
| </Toolbar> | |||
| </AppBar> | |||
| <AppBar | |||
| component="div" | |||
| color="primary" | |||
| position="static" | |||
| elevation={0} | |||
| sx={{ zIndex: 0 }} | |||
| > | |||
| <Toolbar> | |||
| <Grid container alignItems="center" spacing={1}> | |||
| <Grid item xs> | |||
| <Typography color="inherit" variant="h5" component="h1"> | |||
| {headerTitle} | |||
| </Typography> | |||
| </Grid> | |||
| {/* <Grid item> | |||
| <Button | |||
| sx={{ borderColor: lightColor }} | |||
| variant="outlined" | |||
| color="inherit" | |||
| size="small" | |||
| > | |||
| Web setup | |||
| </Button> | |||
| </Grid> | |||
| <Grid item> | |||
| <Tooltip title="Help"> | |||
| <IconButton color="inherit"> | |||
| <HelpIcon /> | |||
| </IconButton> | |||
| </Tooltip> | |||
| </Grid> */} | |||
| </Grid> | |||
| </Toolbar> | |||
| </AppBar> | |||
| <AppBar | |||
| component="div" | |||
| position="static" | |||
| elevation={0} | |||
| sx={{ zIndex: 0 }} | |||
| > | |||
| {!!tabs && <Box sx={{ maxWidth: contentsWidth }}>{tabs}</Box>} | |||
| </AppBar> | |||
| </React.Fragment> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,97 @@ | |||
| import { Box, Typography, styled } from "@mui/material"; | |||
| import { Outlet } from "react-router-dom"; | |||
| import Navigator from "./navigator"; | |||
| import useResponsive from "hooks/useResponsive"; | |||
| import { useContext, useEffect, useMemo, useState } from "react"; | |||
| import Header from "./header"; | |||
| import useWindowSize from "hooks/useWindowSize"; | |||
| import { | |||
| DashboardLayoutContext, | |||
| DashboardLayoutContextProvider, | |||
| } from "contexts/DashboardLayoutContext"; | |||
| import useDashboard from "hooks/useDashBoard"; | |||
| import useAuth from "hooks/useAuth"; | |||
| import useNavigateCustom from "hooks/useNavigateCustom"; | |||
| import { getPath } from "routes/path"; | |||
| import { PageID } from "pages"; | |||
| import useBackDrop from "hooks/useBackDrop"; | |||
| function Copyright() { | |||
| return ( | |||
| <Typography variant="body2" color="text.secondary" align="center"> | |||
| {"Copyright ©Satellite-Technologies Co., Ltd."} | |||
| {new Date().getFullYear()}. All Rights Reserved. | |||
| </Typography> | |||
| ); | |||
| } | |||
| function App() { | |||
| const { initialized, authenticated } = useAuth(); | |||
| const { navigateWhenChanged } = useNavigateCustom(); | |||
| const { setShowBackDrop } = useBackDrop(); | |||
| const [mobileOpen, setMobileOpen] = useState(false); | |||
| const { drawerWidth, innerWidth, contentsWidth } = useDashboard(); | |||
| const handleDrawerToggle = () => { | |||
| setMobileOpen(!mobileOpen); | |||
| }; | |||
| useEffect(() => { | |||
| setShowBackDrop(!initialized); | |||
| if (initialized && !authenticated) { | |||
| navigateWhenChanged(getPath(PageID.PAGE_403)); | |||
| } | |||
| }, [initialized, authenticated]); | |||
| if (!initialized) { | |||
| return null; | |||
| } | |||
| return ( | |||
| <Box sx={{ display: "flex", minHeight: "100vh" }}> | |||
| <Box | |||
| component="nav" | |||
| sx={{ width: { sm: drawerWidth }, flexShrink: { md: 0 } }} | |||
| > | |||
| <Navigator | |||
| PaperProps={{ style: { width: drawerWidth } }} | |||
| variant="temporary" | |||
| open={mobileOpen} | |||
| onClose={handleDrawerToggle} | |||
| /> | |||
| <Navigator | |||
| PaperProps={{ style: { width: drawerWidth } }} | |||
| sx={{ display: { sm: "block", xs: "none" } }} | |||
| /> | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| flex: 1, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| maxWidth: contentsWidth, | |||
| }} | |||
| > | |||
| <Header onDrawerToggle={handleDrawerToggle} /> | |||
| <Box component="main" sx={{ flex: 1, pt: 1, pb: 6, px: 4 }}> | |||
| <Outlet /> | |||
| </Box> | |||
| <Box component="footer" sx={{ p: 2 }}> | |||
| <Copyright /> | |||
| </Box> | |||
| </Box> | |||
| </Box> | |||
| ); | |||
| } | |||
| export default function DashBoardLayout() { | |||
| return ( | |||
| <DashboardLayoutContextProvider> | |||
| <App /> | |||
| </DashboardLayoutContextProvider> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,240 @@ | |||
| import { ExpandLess, ExpandMore } from "@mui/icons-material"; | |||
| import AccountCircleIcon from "@mui/icons-material/AccountCircle"; | |||
| import ArticleIcon from "@mui/icons-material/Article"; | |||
| import PersonIcon from "@mui/icons-material/Person"; | |||
| import SettingsIcon from "@mui/icons-material/Settings"; | |||
| import { Collapse, Typography } from "@mui/material"; | |||
| import Box from "@mui/material/Box"; | |||
| import Divider from "@mui/material/Divider"; | |||
| import Drawer, { DrawerProps } from "@mui/material/Drawer"; | |||
| import List from "@mui/material/List"; | |||
| import ListItem from "@mui/material/ListItem"; | |||
| import ListItemButton from "@mui/material/ListItemButton"; | |||
| import ListItemIcon from "@mui/material/ListItemIcon"; | |||
| import ListItemText from "@mui/material/ListItemText"; | |||
| import { UserRole } from "auth/UserRole"; | |||
| import { ページアクセス許可判定 } from "auth/route"; | |||
| import useAuth from "hooks/useAuth"; | |||
| import useNavigateCustom from "hooks/useNavigateCustom"; | |||
| import usePage from "hooks/usePage"; | |||
| import { PageID } from "pages"; | |||
| import * as React from "react"; | |||
| import { PathOption, getPath } from "routes/path"; | |||
| type Group = { | |||
| label: string; | |||
| children: SubGroup[]; | |||
| }; | |||
| type SubGroup = { | |||
| label: string; | |||
| icon: React.ReactNode; | |||
| children?: Child[]; | |||
| // 子要素を持たない場合は設定 | |||
| id?: PageID; | |||
| option?: PathOption; | |||
| }; | |||
| type Child = { | |||
| label: string; | |||
| id: PageID; | |||
| option?: PathOption; | |||
| }; | |||
| const item = { | |||
| py: "2px", | |||
| px: 3, | |||
| color: "rgba(255, 255, 255, 0.7)", | |||
| "&:hover, &:focus": { | |||
| bgcolor: "rgba(255, 255, 255, 0.08)", | |||
| }, | |||
| }; | |||
| const viewItem = { | |||
| py: "2px", | |||
| px: 3, | |||
| color: "rgba(255, 255, 255, 0.7)", | |||
| }; | |||
| const itemCategory = { | |||
| boxShadow: "0 -1px 0 rgb(255,255,255,0.1) inset", | |||
| py: 1.5, | |||
| px: 3, | |||
| }; | |||
| export default function Navigator(props: DrawerProps) { | |||
| const { ...other } = props; | |||
| const { name } = useAuth(); | |||
| const { navigateWhenChanged } = useNavigateCustom(); | |||
| const navigateToDashboardOverview = () => { | |||
| navigateWhenChanged(getPath(PageID.DASHBOARD_OVERVIEW)); | |||
| }; | |||
| const categories: Group[] = [ | |||
| { | |||
| label: "管理メニュー", | |||
| children: [], | |||
| }, | |||
| { | |||
| label: "QRサービス券", | |||
| children: [ | |||
| { | |||
| label: "券発行用QRコード", | |||
| icon: <ArticleIcon />, | |||
| id: PageID.サービス券発行用QRコード, | |||
| }, | |||
| { | |||
| label: "利用履歴", | |||
| icon: <ArticleIcon />, | |||
| id: PageID.サービス券利用履歴, | |||
| }, | |||
| ], | |||
| }, | |||
| { | |||
| label: "アカウント", | |||
| children: [ | |||
| { label: "ログアウト", icon: <SettingsIcon />, id: PageID.LOGOUT }, | |||
| ], | |||
| }, | |||
| ]; | |||
| return ( | |||
| <Drawer variant="permanent" {...other}> | |||
| <List disablePadding> | |||
| <ListItem | |||
| onClick={navigateToDashboardOverview} | |||
| sx={{ ...item, ...itemCategory, fontSize: 22, color: "#fff" }} | |||
| > | |||
| QRサービス券 | |||
| </ListItem> | |||
| <ListItem sx={{ ...viewItem, ...itemCategory }}> | |||
| <ListItemIcon> | |||
| <AccountCircleIcon /> | |||
| </ListItemIcon> | |||
| <ListItemText>{}</ListItemText> | |||
| <ListItemText> | |||
| <Typography>{name} 様</Typography> | |||
| </ListItemText> | |||
| </ListItem> | |||
| {categories.map((group, index) => { | |||
| return <Group {...group} key={index} />; | |||
| })} | |||
| </List> | |||
| </Drawer> | |||
| ); | |||
| } | |||
| function Group(group: Group) { | |||
| const { currentRole } = useAuth(); | |||
| const { label, children } = group; | |||
| const elements = children | |||
| .filter(({ id }) => ページアクセス許可判定(currentRole, id ?? -1)) | |||
| .map((ele, index) => <SubGroup {...ele} key={index} />); | |||
| if (elements.length === 0) return null; | |||
| return ( | |||
| <Box sx={{ bgcolor: "#101F33" }}> | |||
| <ListItem sx={{ py: 2, px: 3 }}> | |||
| <ListItemText sx={{ color: "#fff" }}>{label}</ListItemText> | |||
| </ListItem> | |||
| {elements} | |||
| <Divider sx={{ mt: 2 }} /> | |||
| </Box> | |||
| ); | |||
| } | |||
| function SubGroup({ icon, label, id, children, option }: SubGroup) { | |||
| const { pageId } = usePage(); | |||
| const { navigateWhenChanged } = useNavigateCustom(); | |||
| const { elements, shouldOpen } = useContents(children ?? []); | |||
| const [open, setOpen] = React.useState(false); | |||
| React.useEffect(() => { | |||
| setOpen(shouldOpen); | |||
| }, [shouldOpen]); | |||
| // 子要素ありの場合 | |||
| if (elements && elements.length !== 0) { | |||
| const handleClick = () => { | |||
| setOpen(!open); | |||
| }; | |||
| return ( | |||
| <> | |||
| <ListItemButton onClick={handleClick} sx={item} selected={false}> | |||
| <ListItemIcon>{icon}</ListItemIcon> | |||
| <ListItemText>{label}</ListItemText> | |||
| {open ? <ExpandLess /> : <ExpandMore />} | |||
| </ListItemButton> | |||
| <Collapse in={open} timeout="auto" unmountOnExit> | |||
| <List component="div" disablePadding> | |||
| {elements} | |||
| </List> | |||
| </Collapse> | |||
| </> | |||
| ); | |||
| } | |||
| // 子要素なしの場合 | |||
| if (id !== undefined) { | |||
| const handleClick = () => { | |||
| if (id) { | |||
| const path = getPath(id, option); | |||
| navigateWhenChanged(path, undefined, { reload: true }); | |||
| } | |||
| }; | |||
| const selected = id === pageId; | |||
| return ( | |||
| <ListItemButton onClick={handleClick} selected={selected} sx={item}> | |||
| <ListItemIcon>{icon}</ListItemIcon> | |||
| <ListItemText>{label}</ListItemText> | |||
| </ListItemButton> | |||
| ); | |||
| } | |||
| return null; | |||
| } | |||
| function useContents(children: Child[]) { | |||
| const { pageId } = usePage(); | |||
| const { navigateWhenChanged } = useNavigateCustom(); | |||
| const { initialized } = useAuth(); | |||
| const [shouldOpen, setShouldOpen] = React.useState(false); | |||
| const elements = React.useMemo(() => { | |||
| setShouldOpen(false); | |||
| return children.map(({ label, id, option }, index) => { | |||
| const selected = id === pageId; | |||
| if (selected) { | |||
| setShouldOpen(true); | |||
| } | |||
| const handleClick = () => { | |||
| const path = getPath(id, option); | |||
| navigateWhenChanged(path, undefined, { reload: true }); | |||
| }; | |||
| return ( | |||
| <ListItemButton | |||
| selected={selected} | |||
| sx={{ ...item, pl: 4 }} | |||
| key={index} | |||
| onClick={handleClick} | |||
| > | |||
| <ListItemText primary={label} /> | |||
| </ListItemButton> | |||
| ); | |||
| }); | |||
| }, [pageId, initialized]); | |||
| return { | |||
| elements, | |||
| shouldOpen, | |||
| }; | |||
| } | |||
| @@ -0,0 +1,31 @@ | |||
| import { Tabs } from "@mui/material"; | |||
| import { TabProps, useTab } from "./tabutil"; | |||
| import { PageID, TabID } from "pages"; | |||
| import { getPath } from "routes/path"; | |||
| const tabs: TabProps[] = [ | |||
| { | |||
| label: "一覧", | |||
| tabId: TabID.NONE, | |||
| }, | |||
| { | |||
| label: "詳細", | |||
| tabId: TabID.A, | |||
| }, | |||
| ]; | |||
| export default function ContractTabs() { | |||
| const { elements, getTabIndex } = useTab(tabs); | |||
| return ( | |||
| <Tabs | |||
| value={getTabIndex} | |||
| textColor="inherit" | |||
| // scrollButtons | |||
| // allowScrollButtonsMobile | |||
| variant="scrollable" | |||
| > | |||
| {elements} | |||
| </Tabs> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,17 @@ | |||
| import { Tab as MuiTab, TabProps as MuiTabProps } from "@mui/material"; | |||
| import useNavigateCustom from "hooks/useNavigateCustom"; | |||
| export type TabProps = { | |||
| navigate?: string; | |||
| } & MuiTabProps; | |||
| export function Tab({ navigate, ...others }: TabProps) { | |||
| const { navigateWhenChanged } = useNavigateCustom(); | |||
| const handleClick = () => { | |||
| if (navigate) { | |||
| navigateWhenChanged(navigate); | |||
| } | |||
| }; | |||
| return <MuiTab onClick={handleClick} {...others} />; | |||
| } | |||
| @@ -0,0 +1,34 @@ | |||
| import { PageID, TabID } from "pages"; | |||
| import usePage from "hooks/usePage"; | |||
| import { useMemo } from "react"; | |||
| import { Tab } from "."; | |||
| import { getPath } from "routes/path"; | |||
| export type TabProps = { | |||
| label: string; | |||
| tabId: TabID; | |||
| }; | |||
| export function useTab(tabs: TabProps[]) { | |||
| const { pageId, tabId } = usePage(); | |||
| const elements = useMemo(() => { | |||
| return tabs.map(({ label, tabId: elementTabId }, index) => { | |||
| const path = getPath([pageId, tabId]); | |||
| return <Tab {...{ label, navigate: path, key: index }} />; | |||
| }); | |||
| }, [tabs]); | |||
| const getTabIndex = useMemo(() => { | |||
| return ( | |||
| tabs.findIndex((tab) => { | |||
| return tab.tabId === tabId; | |||
| }) ?? 0 | |||
| ); | |||
| }, [tabId, tabs]); | |||
| return { | |||
| elements, | |||
| getTabIndex, | |||
| }; | |||
| } | |||
| @@ -0,0 +1,26 @@ | |||
| import { AppBar, Box, Grid } from "@mui/material"; | |||
| import { Outlet } from "react-router-dom"; | |||
| export default function QRServiceSimpleLayout() { | |||
| return ( | |||
| <Box> | |||
| <AppBar | |||
| component="div" | |||
| color="primary" | |||
| position="static" | |||
| elevation={0} | |||
| sx={{ zIndex: 0 }} | |||
| > | |||
| <Grid container> | |||
| <Grid item xs /> | |||
| <Grid item xs={5} textAlign="center"> | |||
| QRサービス券 | |||
| </Grid> | |||
| <Grid item xs /> | |||
| </Grid> | |||
| </AppBar> | |||
| <Outlet /> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,26 @@ | |||
| import { AppBar, Box, Grid } from "@mui/material"; | |||
| import { Outlet } from "react-router-dom"; | |||
| export default function SimpleLayout() { | |||
| return ( | |||
| <Box> | |||
| <AppBar | |||
| component="div" | |||
| color="primary" | |||
| position="static" | |||
| elevation={0} | |||
| sx={{ zIndex: 0 }} | |||
| > | |||
| <Grid container> | |||
| <Grid item xs /> | |||
| <Grid item xs={5} textAlign="center"> | |||
| MyPage | |||
| </Grid> | |||
| <Grid item xs /> | |||
| </Grid> | |||
| </AppBar> | |||
| <Outlet /> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1 @@ | |||
| <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg> | |||
| @@ -0,0 +1,87 @@ | |||
| import { yupResolver } from "@hookform/resolvers/yup"; | |||
| import { LoadingButton } from "@mui/lab"; | |||
| import { Box, Stack, Typography } from "@mui/material"; | |||
| import InputAlert from "components/form/InputAlert"; | |||
| import { FormProvider, RHFTextField } from "components/hook-form"; | |||
| import useAuth from "hooks/useAuth"; | |||
| import useNavigateCustom from "hooks/useNavigateCustom"; | |||
| import useSnackbarCustom from "hooks/useSnackbarCustom"; | |||
| import { PageID } from "pages"; | |||
| import { useEffect, useState } from "react"; | |||
| import { useForm } from "react-hook-form"; | |||
| import { useLocation } from "react-router-dom"; | |||
| import { getPath } from "routes/path"; | |||
| import * as Yup from "yup"; | |||
| type FormProps = { | |||
| email: string; | |||
| password: string; | |||
| }; | |||
| const LoginSchema = Yup.object().shape({ | |||
| email: Yup.string().required("必須項目です"), | |||
| password: Yup.string().required("必須項目です"), | |||
| }); | |||
| export default function Login() { | |||
| const { success, error } = useSnackbarCustom(); | |||
| const { pathname } = useLocation(); | |||
| const [message, setMessage] = useState(""); | |||
| const [sending, setSending] = useState(false); | |||
| const { initialized, authenticated, login } = useAuth(); | |||
| const { navigateWhenChanged } = useNavigateCustom(); | |||
| const form = useForm<FormProps>({ | |||
| defaultValues: { | |||
| email: "", | |||
| password: "", | |||
| }, | |||
| resolver: yupResolver(LoginSchema), | |||
| }); | |||
| const handleSubmit = async (data: FormProps) => { | |||
| setMessage(""); | |||
| setSending(true); | |||
| const ret = await login(data.email, data.password); | |||
| setSending(false); | |||
| if (ret) { | |||
| success("ログイン成功"); | |||
| } else { | |||
| error("ログイン失敗"); | |||
| setMessage("入力情報を確認してください"); | |||
| } | |||
| }; | |||
| useEffect(() => { | |||
| if (authenticated === true && pathname === getPath(PageID.LOGIN)) { | |||
| navigateWhenChanged(getPath(PageID.DASHBOARD_OVERVIEW)); | |||
| } | |||
| }, [authenticated]); | |||
| return ( | |||
| <FormProvider methods={form} onSubmit={form.handleSubmit(handleSubmit)}> | |||
| <Box sx={{ p: 3, pt: 5, mx: "auto", maxWidth: 500 }} textAlign="center"> | |||
| <Stack spacing={3}> | |||
| <Typography variant="h5">ログイン</Typography> | |||
| <InputAlert error="none" message={message} /> | |||
| <RHFTextField name="email" label="email" size="small" /> | |||
| <RHFTextField | |||
| name="password" | |||
| type="password" | |||
| label="password" | |||
| size="small" | |||
| /> | |||
| <LoadingButton loading={sending} type="submit" variant="contained"> | |||
| ログイン | |||
| </LoadingButton> | |||
| </Stack> | |||
| </Box> | |||
| </FormProvider> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,21 @@ | |||
| import { PageID } from "pages"; | |||
| import useAuth from "hooks/useAuth"; | |||
| import useNavigateCustom from "hooks/useNavigateCustom"; | |||
| import useSnackbarCustom from "hooks/useSnackbarCustom"; | |||
| import { useEffect } from "react"; | |||
| import { getPath } from "routes/path"; | |||
| export default function Logout() { | |||
| const { logout } = useAuth(); | |||
| const { info, success } = useSnackbarCustom(); | |||
| const { navigateWhenChanged } = useNavigateCustom(); | |||
| useEffect(() => { | |||
| logout(); | |||
| info("ログアウトしました"); | |||
| navigateWhenChanged(getPath(PageID.LOGIN)); | |||
| }, []); | |||
| return null; | |||
| } | |||
| @@ -0,0 +1,5 @@ | |||
| import { Box } from "@mui/material"; | |||
| export default function Page403() { | |||
| return <Box>Un Authenticated.</Box>; | |||
| } | |||
| @@ -0,0 +1,28 @@ | |||
| import { Box } from "@mui/material"; | |||
| import { PageID } from "pages"; | |||
| import useAuth from "hooks/useAuth"; | |||
| import useNavigateCustom from "hooks/useNavigateCustom"; | |||
| import { useEffect } from "react"; | |||
| import { getPath } from "routes/path"; | |||
| import { useLocation } from "react-router-dom"; | |||
| export default function Page404() { | |||
| const { navigateWhenChanged } = useNavigateCustom(); | |||
| const { pathname } = useLocation(); | |||
| const { authenticated, initialized } = useAuth(); | |||
| useEffect(() => { | |||
| if (!initialized) return; | |||
| if (authenticated) { | |||
| console.log(pathname, "404 to dashboard"); | |||
| navigateWhenChanged(getPath(PageID.DASHBOARD_OVERVIEW)); | |||
| return; | |||
| } else { | |||
| console.log(pathname, "404 to login"); | |||
| navigateWhenChanged(getPath(PageID.LOGIN)); | |||
| return; | |||
| } | |||
| }, [initialized, authenticated]); | |||
| return null; | |||
| } | |||
| @@ -0,0 +1,28 @@ | |||
| import { Box, Card, CardContent, CardHeader, Typography } from "@mui/material"; | |||
| import useDashboard from "hooks/useDashBoard"; | |||
| import { PageID, TabID } from "pages"; | |||
| import { useEffect } from "react"; | |||
| export default function Overview() { | |||
| const { setHeaderTitle, setTabs } = useDashboard( | |||
| PageID.DASHBOARD_OVERVIEW, | |||
| TabID.NONE | |||
| ); | |||
| useEffect(() => { | |||
| setHeaderTitle("ポータル"); | |||
| setTabs(null); | |||
| }, [setHeaderTitle, setTabs]); | |||
| return ( | |||
| <Box sx={{ p: 1, m: 1 }}> | |||
| <Card> | |||
| <CardContent> | |||
| <Typography variant="h6" mb={3}> | |||
| お知らせ | |||
| </Typography> | |||
| <Typography variant="body2">特にありません</Typography> | |||
| </CardContent> | |||
| </Card> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,3 @@ | |||
| export default function サービス券利用履歴() { | |||
| return null; | |||
| } | |||
| @@ -0,0 +1,49 @@ | |||
| import { Box, Button, Paper, Stack } from "@mui/material"; | |||
| import useDashboard from "hooks/useDashBoard"; | |||
| import useNavigateCustom from "hooks/useNavigateCustom"; | |||
| import { PageID, TabID } from "pages"; | |||
| import QRCode from "qrcode"; | |||
| import { useEffect, useState } from "react"; | |||
| export default function サービス券発行用QRコード() { | |||
| const { setHeaderTitle, setTabs } = useDashboard( | |||
| PageID.サービス券発行用QRコード, | |||
| TabID.NONE | |||
| ); | |||
| const size = 200; | |||
| const url = "http://localhost:8080/dashboard/qr/generate"; | |||
| const { navigateWhenChanged } = useNavigateCustom(); | |||
| const [qr, setQr] = useState(""); | |||
| useEffect(() => { | |||
| QRCode.toDataURL(url, { | |||
| errorCorrectionLevel: "H", | |||
| }).then((data: string) => { | |||
| setQr(data); | |||
| }); | |||
| }, []); | |||
| useEffect(() => { | |||
| setHeaderTitle("サービス券発行用QRコード"); | |||
| }, [setHeaderTitle, setTabs]); | |||
| return ( | |||
| <Box> | |||
| <Stack spacing={2}> | |||
| <Paper sx={{ p: 2 }}> | |||
| <Stack> | |||
| <Box mx="auto"> | |||
| {!!qr && <img src={qr} width={size} height={size}></img>} | |||
| </Box> | |||
| <Box mx="auto"> | |||
| <Button variant="contained">印刷用</Button> | |||
| <Button variant="contained">QRコード変更</Button> | |||
| </Box> | |||
| </Stack> | |||
| </Paper> | |||
| </Stack> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| let id = 0; | |||
| export const PageID = { | |||
| NONE: id++, | |||
| LOGIN: id++, | |||
| LOGOUT: id++, | |||
| DASHBOARD_OVERVIEW: id++, | |||
| サービス券発行用QRコード: id++, | |||
| サービス券利用履歴: id++, | |||
| QRサービス券発行申請: id++, | |||
| PAGE_403: id++, | |||
| PAGE_404: id++, | |||
| } as const; | |||
| export type PageID = (typeof PageID)[keyof typeof PageID]; | |||
| id = 0; | |||
| export const TabID = { | |||
| NONE: id++, | |||
| A: id++, | |||
| } as const; | |||
| export type TabID = (typeof TabID)[keyof typeof TabID]; | |||
| @@ -0,0 +1,85 @@ | |||
| import QRCode from "qrcode"; | |||
| import { Box, Button, Paper, Stack, Typography } from "@mui/material"; | |||
| import { useEffect, useState } from "react"; | |||
| import { useParams } from "react-router-dom"; | |||
| import useAPICall from "hooks/useAPICall"; | |||
| import { QRサービス券取得, QRサービス券取得Request } from "api/qr-service"; | |||
| import { StoreId, getStore, setStore } from "storage/localstorage"; | |||
| const size = 200; | |||
| export default function QRサービス券発行申請() { | |||
| const { token: paramToken } = useParams(); | |||
| const [failed, setFailed] = useState<boolean | undefined>(undefined); | |||
| const [qrStr, setQrStr] = useState(""); | |||
| const [qr, setQr] = useState(""); | |||
| const { callAPI: callQRサービス券取得 } = useAPICall({ | |||
| apiMethod: QRサービス券取得, | |||
| backDrop: true, | |||
| onSuccess: ({ data }) => { | |||
| setQrStr(data.data); | |||
| setStore(StoreId.QRサービス券チケットID, data.ticket_id); | |||
| setFailed(false); | |||
| }, | |||
| onFailed: () => { | |||
| setFailed(true); | |||
| }, | |||
| }); | |||
| const handleClick = () => { | |||
| const param: QRサービス券取得Request = { | |||
| token: paramToken ?? "noparam", | |||
| }; | |||
| const ticketId = getStore(StoreId.QRサービス券チケットID); | |||
| if (ticketId) { | |||
| param.ticket_id = ticketId; | |||
| } | |||
| callQRサービス券取得(param); | |||
| }; | |||
| // QRコードの作成 | |||
| useEffect(() => { | |||
| if (!!qrStr) { | |||
| QRCode.toDataURL(qrStr, { | |||
| errorCorrectionLevel: "H", | |||
| }).then((data: string) => { | |||
| setQr(data); | |||
| }); | |||
| } | |||
| }, [qrStr]); | |||
| if (failed === true) { | |||
| return ( | |||
| <Box> | |||
| <Stack sx={{ p: 2 }}> | |||
| <Box mx="auto"> | |||
| <Typography>取得に失敗しました。店舗へお問合せ下さい。</Typography> | |||
| </Box> | |||
| </Stack> | |||
| </Box> | |||
| ); | |||
| } | |||
| return ( | |||
| <Box> | |||
| <Stack spacing={2}> | |||
| <Paper sx={{ p: 2 }}> | |||
| <Stack> | |||
| <Box mx="auto"> | |||
| {!!qr && <img src={qr} width={size} height={size}></img>} | |||
| </Box> | |||
| {!qrStr && ( | |||
| <Box mx="auto"> | |||
| <Button variant="contained" onClick={handleClick}> | |||
| サービス券取得 | |||
| </Button> | |||
| </Box> | |||
| )} | |||
| </Stack> | |||
| </Paper> | |||
| </Stack> | |||
| </Box> | |||
| ); | |||
| } | |||
| @@ -0,0 +1,17 @@ | |||
| import { csrfToken } from "api/auth"; | |||
| import { memo, useEffect, useState } from "react"; | |||
| function CsrfTokenProvider() { | |||
| const [done, setDone] = useState(false); | |||
| useEffect(() => { | |||
| if (!done) { | |||
| setDone(true); | |||
| csrfToken(); | |||
| } | |||
| }, [done]); | |||
| return null; | |||
| } | |||
| export default memo(CsrfTokenProvider); | |||
| @@ -0,0 +1,9 @@ | |||
| import { HasChildren } from "@types"; | |||
| import { SnackbarProvider as NotistackProvider } from "notistack"; | |||
| type Props = HasChildren; | |||
| export default function SnackbarProvider({ children }: Props) { | |||
| return ( | |||
| <NotistackProvider autoHideDuration={1000}>{children}</NotistackProvider> | |||
| ); | |||
| } | |||
| @@ -0,0 +1 @@ | |||
| /// <reference types="react-scripts" /> | |||
| @@ -0,0 +1,15 @@ | |||
| import { ReportHandler } from 'web-vitals'; | |||
| const reportWebVitals = (onPerfEntry?: ReportHandler) => { | |||
| if (onPerfEntry && onPerfEntry instanceof Function) { | |||
| import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { | |||
| getCLS(onPerfEntry); | |||
| getFID(onPerfEntry); | |||
| getFCP(onPerfEntry); | |||
| getLCP(onPerfEntry); | |||
| getTTFB(onPerfEntry); | |||
| }); | |||
| } | |||
| }; | |||
| export default reportWebVitals; | |||
| @@ -0,0 +1,83 @@ | |||
| import { PageID } from "pages"; | |||
| import LoadingScreen from "components/LoadingScreen"; | |||
| import useAuth from "hooks/useAuth"; | |||
| import SimpleLayout from "layouts/simple"; | |||
| import { ElementType, Suspense, lazy, useMemo } from "react"; | |||
| import { RouteObject, useRoutes } from "react-router-dom"; | |||
| import { getRoute } from "./path"; | |||
| import DashboardRoutes from "./sub/dashboard"; | |||
| import QRServiceSimpleLayout from "layouts/qr-service"; | |||
| export const Loadable = (Component: ElementType) => (props: any) => { | |||
| return ( | |||
| <Suspense fallback={<LoadingScreen />}> | |||
| <Component {...props} /> | |||
| </Suspense> | |||
| ); | |||
| }; | |||
| const CommonRoutes = (): RouteObject => ({ | |||
| element: <SimpleLayout />, | |||
| children: [{}], | |||
| }); | |||
| const AuthRoutes = (): RouteObject => ({ | |||
| element: <SimpleLayout />, | |||
| children: [ | |||
| { | |||
| path: getRoute(PageID.LOGIN), | |||
| element: <Login />, | |||
| }, | |||
| { | |||
| path: getRoute(PageID.LOGOUT), | |||
| element: <Logout />, | |||
| }, | |||
| ], | |||
| }); | |||
| const QRサービス券Routes = (): RouteObject => ({ | |||
| element: <QRServiceSimpleLayout />, | |||
| children: [ | |||
| { | |||
| path: getRoute(PageID.QRサービス券発行申請), | |||
| element: <QRサービス券発行申請 />, | |||
| }, | |||
| ], | |||
| }); | |||
| export function Routes() { | |||
| const { initialized } = useAuth(); | |||
| return useRoutes([ | |||
| CommonRoutes(), | |||
| AuthRoutes(), | |||
| QRサービス券Routes(), | |||
| ...DashboardRoutes(), | |||
| { | |||
| path: "403", | |||
| element: <Page403 />, | |||
| }, | |||
| { | |||
| path: "*", | |||
| element: initialized ? <Page404 /> : <LoadingScreen />, | |||
| }, | |||
| ]); | |||
| } | |||
| // 認証関連 ------------------------------- | |||
| const Login = Loadable(lazy(() => import("pages/auth/login"))); | |||
| const Logout = Loadable(lazy(() => import("pages/auth/logout"))); | |||
| // QRサービス券関連 | |||
| const QRサービス券発行申請 = Loadable( | |||
| lazy(() => import("pages/qr-service/QRサービス券発行申請")) | |||
| ); | |||
| // その他 --------------------------------- | |||
| // const Page403 = Loadable(lazy(() => import("pages/common/Page403"))); | |||
| const Page404 = Loadable(lazy(() => import("pages/common/Page404"))); | |||
| const PP = (path: string) => { | |||
| return Loadable(lazy(() => import(path))); | |||
| }; | |||
| const Page403 = PP("pages/common/Page403"); | |||
| @@ -0,0 +1,106 @@ | |||
| import { Dictionary } from "@types"; | |||
| import { PageID, TabID } from "pages"; | |||
| import { get, isArray, isString, replace } from "lodash"; | |||
| type PathKey = [PageID, TabID?] | PageID; | |||
| const makePathKey = (arg: PathKey): string => { | |||
| if (isArray(arg)) { | |||
| const tabStr = arg[1] !== undefined ? "/" + String(arg[1]) : ""; | |||
| return String(arg[0]) + tabStr; | |||
| } else { | |||
| return String(arg); | |||
| } | |||
| }; | |||
| const getPageId = (key: PathKey): PageID => { | |||
| if (isArray(key)) { | |||
| return key[0]; | |||
| } else { | |||
| return key; | |||
| } | |||
| }; | |||
| const getTabId = (key: PathKey): TabID => { | |||
| if (isArray(key)) { | |||
| return key[1] ?? TabID.NONE; | |||
| } else { | |||
| return TabID.NONE; | |||
| } | |||
| }; | |||
| const PATHS_DASHBOARD = { | |||
| [makePathKey(PageID.DASHBOARD_OVERVIEW)]: "/dashboard", | |||
| [makePathKey(PageID.サービス券発行用QRコード)]: "/dashboard/qrcode/generate", | |||
| [makePathKey(PageID.サービス券利用履歴)]: "/dashboard/qrcode/history", | |||
| }; | |||
| const PATHS = { | |||
| [makePathKey(PageID.NONE)]: "/", | |||
| // 認証 | |||
| [makePathKey(PageID.LOGIN)]: "/login", | |||
| [makePathKey(PageID.LOGOUT)]: "/logout", | |||
| [makePathKey(PageID.QRサービス券発行申請)]: "qr-service/acquitision/:token", | |||
| // ダッシュボード---------------- | |||
| ...PATHS_DASHBOARD, | |||
| // その他 | |||
| [makePathKey(PageID.PAGE_403)]: "403", | |||
| [makePathKey(PageID.PAGE_404)]: "404", | |||
| }; | |||
| export type PathOption = { | |||
| page?: number; | |||
| query?: Dictionary; | |||
| }; | |||
| export function getPath(key: PathKey, option?: PathOption) { | |||
| const pageId = getPageId(key); | |||
| const tabId = getTabId(key); | |||
| let path = getRoute(pageId); | |||
| // ページ番号解決 | |||
| path = replacePathParam(path, "page", option?.page ?? 0); | |||
| // その他URLパラメータ変換 | |||
| if (option?.query !== undefined) { | |||
| Object.keys(option.query).forEach((key) => { | |||
| const value = get(option.query, key); | |||
| if (value === undefined) return; | |||
| path = replacePathParam(path, key, value); | |||
| }); | |||
| } | |||
| return path; | |||
| } | |||
| export function getListPagePath(key: PathKey, page: number): string { | |||
| return getPath(key, { page }); | |||
| } | |||
| export function getRoute(key: PathKey, exclude?: string): string { | |||
| let path = get(PATHS, makePathKey(key)); | |||
| if (!path) throw new Error("ルート未定義:" + makePathKey(key)); | |||
| if (exclude) { | |||
| path = replace(path, "/" + exclude + "/", ""); | |||
| } | |||
| return path; | |||
| } | |||
| function replacePathParam( | |||
| sourceStr: string, | |||
| searchStr: string, | |||
| replacement: string | number | |||
| ): string { | |||
| return replace( | |||
| sourceStr, | |||
| ":" + searchStr, | |||
| isString(replacement) ? replacement : String(replacement) | |||
| ); | |||
| } | |||
| @@ -0,0 +1,62 @@ | |||
| import { ページアクセス許可判定 } from "auth/route"; | |||
| import AuthGuard from "guards/AuthGuard"; | |||
| import useAuth from "hooks/useAuth"; | |||
| import DashboardLayout from "layouts/dashbord"; | |||
| import { PageID } from "pages"; | |||
| import { lazy, useMemo } from "react"; | |||
| import { RouteObject } from "react-router-dom"; | |||
| import { Loadable } from "routes"; | |||
| import { getRoute } from "routes/path"; | |||
| export default function DashboardRoutes(): RouteObject[] { | |||
| const { currentRole } = useAuth(); | |||
| const children: RouteObject[] = useMemo(() => { | |||
| const Dashboard = Loadable(lazy(() => import("pages/dashboard"))); | |||
| const サービス券発行用QRコード = Loadable( | |||
| lazy(() => import("pages/dashboard/qrcode/サービス券発行用QRコード")) | |||
| ); | |||
| const サービス券利用履歴 = Loadable( | |||
| lazy(() => import("pages/dashboard/qrcode/サービス券利用履歴")) | |||
| ); | |||
| const allChildren = [ | |||
| { | |||
| pageId: PageID.DASHBOARD_OVERVIEW, | |||
| element: <Dashboard />, | |||
| }, | |||
| { | |||
| pageId: PageID.サービス券発行用QRコード, | |||
| element: <サービス券発行用QRコード />, | |||
| }, | |||
| { | |||
| pageId: PageID.サービス券利用履歴, | |||
| element: <サービス券利用履歴 />, | |||
| }, | |||
| ]; | |||
| return allChildren | |||
| .filter(({ pageId }) => { | |||
| if (currentRole === null) { | |||
| return false; | |||
| } | |||
| return ページアクセス許可判定(currentRole, pageId); | |||
| }) | |||
| .map(({ pageId, ...others }) => ({ | |||
| ...others, | |||
| path: getRoute(pageId), | |||
| })); | |||
| }, [currentRole]); | |||
| return [ | |||
| { | |||
| element: ( | |||
| <AuthGuard> | |||
| <DashboardLayout /> | |||
| </AuthGuard> | |||
| ), | |||
| children: children, | |||
| }, | |||
| ]; | |||
| } | |||
| @@ -0,0 +1,5 @@ | |||
| // jest-dom adds custom jest matchers for asserting on DOM nodes. | |||
| // allows you to do things like: | |||
| // expect(element).toHaveTextContent(/react/i) | |||
| // learn more: https://github.com/testing-library/jest-dom | |||
| import '@testing-library/jest-dom'; | |||
| @@ -0,0 +1,18 @@ | |||
| export const StoreId = { | |||
| // 取得済みのサービス券IDを保持 | |||
| QRサービス券チケットID: "QRサービス券チケットID", | |||
| } as const; | |||
| export type StoreId = (typeof StoreId)[keyof typeof StoreId]; | |||
| export const getStore = (id: StoreId): string | null => { | |||
| return localStorage.getItem(id); | |||
| }; | |||
| export const setStore = (id: StoreId, data: any) => { | |||
| localStorage.setItem(id, data); | |||
| }; | |||
| export const removeStore = (id: StoreId) => { | |||
| localStorage.removeItem(id); | |||
| }; | |||
| export const clearStore = () => { | |||
| localStorage.clear(); | |||
| }; | |||
| @@ -0,0 +1,201 @@ | |||
| import { ThemeProvider, createTheme } from "@mui/material"; | |||
| import { HasChildren } from "@types"; | |||
| import { memo, useMemo } from "react"; | |||
| let theme = createTheme({ | |||
| palette: { | |||
| primary: { | |||
| // オレンジ系統 | |||
| // light: "#FFCCBC", | |||
| // main: "#FF8A65", | |||
| // dark: "#F4511E", | |||
| // 水色系統 | |||
| light: "#22D3EE", | |||
| main: "#06B6D4", | |||
| dark: "#0891B2", | |||
| // ピンク系統 | |||
| // light: "#FB7185", | |||
| // main: "#F43F5E", | |||
| // dark: "#E11D48", | |||
| }, | |||
| }, | |||
| typography: { | |||
| h5: { | |||
| fontWeight: 500, | |||
| fontSize: 26, | |||
| letterSpacing: 0.5, | |||
| }, | |||
| }, | |||
| shape: { | |||
| borderRadius: 8, | |||
| }, | |||
| components: { | |||
| MuiTab: { | |||
| defaultProps: { | |||
| disableRipple: true, | |||
| }, | |||
| }, | |||
| }, | |||
| mixins: { | |||
| toolbar: { | |||
| minHeight: 48, | |||
| }, | |||
| }, | |||
| }); | |||
| theme = { | |||
| ...theme, | |||
| components: { | |||
| MuiDrawer: { | |||
| styleOverrides: { | |||
| paper: { | |||
| backgroundColor: "#081627", | |||
| }, | |||
| }, | |||
| }, | |||
| MuiTableHead: { | |||
| styleOverrides: { | |||
| root: { | |||
| // backgroundColor: "#D1E6D6", | |||
| backgroundColor: "rgb(255,255,255,0.15)", | |||
| }, | |||
| }, | |||
| }, | |||
| MuiButton: { | |||
| styleOverrides: { | |||
| root: { | |||
| textTransform: "none", | |||
| }, | |||
| contained: { | |||
| boxShadow: "none", | |||
| "&:active": { | |||
| boxShadow: "none", | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| MuiTabs: { | |||
| styleOverrides: { | |||
| root: { | |||
| marginLeft: theme.spacing(1), | |||
| }, | |||
| indicator: { | |||
| height: 3, | |||
| borderTopLeftRadius: 3, | |||
| borderTopRightRadius: 3, | |||
| backgroundColor: theme.palette.common.white, | |||
| }, | |||
| }, | |||
| }, | |||
| MuiTab: { | |||
| styleOverrides: { | |||
| root: { | |||
| textTransform: "none", | |||
| margin: "0 16px", | |||
| minWidth: 0, | |||
| padding: 0, | |||
| [theme.breakpoints.up("md")]: { | |||
| padding: 0, | |||
| minWidth: 0, | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| MuiIconButton: { | |||
| styleOverrides: { | |||
| root: { | |||
| padding: theme.spacing(1), | |||
| }, | |||
| }, | |||
| }, | |||
| MuiTooltip: { | |||
| styleOverrides: { | |||
| tooltip: { | |||
| borderRadius: 4, | |||
| }, | |||
| }, | |||
| }, | |||
| MuiDivider: { | |||
| styleOverrides: { | |||
| root: { | |||
| backgroundColor: "rgb(255,255,255,0.15)", | |||
| }, | |||
| }, | |||
| }, | |||
| MuiListItemButton: { | |||
| styleOverrides: { | |||
| root: { | |||
| "&.Mui-selected": { | |||
| color: "#4fc3f7", | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| MuiListItemText: { | |||
| styleOverrides: { | |||
| primary: { | |||
| fontSize: 14, | |||
| fontWeight: theme.typography.fontWeightMedium, | |||
| }, | |||
| }, | |||
| }, | |||
| MuiListItemIcon: { | |||
| styleOverrides: { | |||
| root: { | |||
| color: "inherit", | |||
| minWidth: "auto", | |||
| marginRight: theme.spacing(2), | |||
| "& svg": { | |||
| fontSize: 20, | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| MuiAvatar: { | |||
| styleOverrides: { | |||
| root: { | |||
| width: 32, | |||
| height: 32, | |||
| }, | |||
| }, | |||
| }, | |||
| MuiInput: { | |||
| styleOverrides: { | |||
| root: { | |||
| "&.Mui-disabled:before": { | |||
| borderBottomStyle: "none", | |||
| }, | |||
| "&.Mui-disabled": { | |||
| WebkitTextFillColor: "black !important", | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| MuiTableRow: { | |||
| styleOverrides: { | |||
| root: { | |||
| "&:last-child td, &:last-child th ": { | |||
| borderBottomStyle: "none", | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| MuiGrid: { | |||
| styleOverrides: { | |||
| root: { | |||
| "&>.MuiGrid-item": { | |||
| paddingTop: theme.spacing(1), | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| }, | |||
| }; | |||
| type Props = HasChildren; | |||
| function AppThemeProvider({ children }: Props) { | |||
| const t = useMemo(() => theme, []); | |||
| return <ThemeProvider theme={t}>{children}</ThemeProvider>; | |||
| } | |||
| export default memo(AppThemeProvider); | |||