| @@ -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,63 @@ | |||
| { | |||
| "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/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", | |||
| "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>MyPage</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,42 @@ | |||
| import { APICommonResponse, ApiId, HttpMethod, request } from "."; | |||
| import { getUrl } from "./url"; | |||
| type MeResponse = { | |||
| data: { | |||
| id: string; | |||
| name: string; | |||
| email: string; | |||
| }; | |||
| } & 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<MeResponse>({ | |||
| 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,255 @@ | |||
| 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++, | |||
| } 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(); | |||
| } | |||
| } | |||
| 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,30 @@ | |||
| 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", | |||
| }; | |||
| 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,67 @@ | |||
| export type GetAddressFromZipCodeResponse = { | |||
| success: boolean; | |||
| zipcode: string; | |||
| prefcode?: string; | |||
| address1?: string; | |||
| address2?: string; | |||
| }; | |||
| type APIResponse = { | |||
| status: number; | |||
| message: string | null; | |||
| results: | |||
| | { | |||
| zipcode: string; // 郵便番号 7桁の郵便番号。ハイフンなし。 | |||
| prefcode: string; // 都道府県コード JIS X 0401 に定められた2桁の都道府県コード。 | |||
| address1: string; // 都道府県名 | |||
| address2: string; // 市区町村名 | |||
| address3: string; // 町域名 | |||
| kana1: string; // 都道府県名カナ | |||
| kana2: string; // 市区町村名カナ | |||
| kana3: string; // | |||
| }[] | |||
| | null; | |||
| }; | |||
| // 郵便番号から住所を検索するAPIを呼ぶ | |||
| export async function getAddressFromZipCode( | |||
| zipcode: string | |||
| ): Promise<GetAddressFromZipCodeResponse> { | |||
| const url = 'https://zipcloud.ibsnet.co.jp/api/search?zipcode=' + zipcode; | |||
| try { | |||
| const data = await callAPI(url); | |||
| if (data.status !== 200) { | |||
| throw Error('ステータス不正:' + data.status); | |||
| } | |||
| if (data.results === null || data.results.length === 0) { | |||
| throw Error('結果0件:' + data.status); | |||
| } | |||
| const target = data.results[0]; | |||
| // 正常返却 | |||
| return { | |||
| success: true, | |||
| zipcode, | |||
| prefcode: target.prefcode, | |||
| address1: target.address2, | |||
| address2: target.address3, | |||
| }; | |||
| } catch (e) { | |||
| if (e instanceof Error) { | |||
| console.error('zipcode error', e.message); | |||
| } | |||
| // エラー返却 | |||
| return { | |||
| success: false, | |||
| zipcode, | |||
| }; | |||
| } | |||
| } | |||
| async function callAPI(url: string): Promise<APIResponse> { | |||
| const data = await (await fetch(url)).json(); | |||
| return data; | |||
| } | |||
| @@ -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,5 @@ | |||
| import { Box } from "@mui/material"; | |||
| export default function LoadingScreen() { | |||
| 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,56 @@ | |||
| import { Alert, SxProps } from "@mui/material"; | |||
| import { APIErrorType } from "hooks/useAPICall"; | |||
| import React, { useEffect, useMemo, useRef } from "react"; | |||
| type Props = { | |||
| error: APIErrorType; | |||
| sx?: SxProps; | |||
| getMessage?: (errpr: APIErrorType) => string | null; | |||
| message?: string; | |||
| errorScroll?: boolean; | |||
| }; | |||
| const InputAlert = ({ | |||
| error, | |||
| sx, | |||
| getMessage, | |||
| message: errorMessage, | |||
| errorScroll, | |||
| }: Props) => { | |||
| const ref = useRef<HTMLDivElement>(null); | |||
| const message = useMemo(() => { | |||
| if (errorMessage) { | |||
| return errorMessage; | |||
| } | |||
| if (getMessage) { | |||
| const m = getMessage(error); | |||
| if (m !== null) { | |||
| return m; | |||
| } | |||
| } | |||
| if (error === APIErrorType.INPUT) return "入力項目を確認してください。"; | |||
| if (error === APIErrorType.SERVER) | |||
| return "エラーが発生しております。しばらくお待ちください。"; | |||
| if (error === APIErrorType.EXCLUSIVE) | |||
| return "ページの期限が切れています。再度読込を行ってください"; | |||
| return ""; | |||
| }, [error, errorMessage, getMessage]); | |||
| // エラー時に自動的にスクロール制御 | |||
| useEffect(() => { | |||
| if (error !== APIErrorType.NONE && errorScroll) { | |||
| if (ref.current) { | |||
| ref.current.scrollIntoView({ block: "center", behavior: "smooth" }); | |||
| } | |||
| } | |||
| }, [error, ref]); | |||
| if (message === "" && error === APIErrorType.NONE) 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,128 @@ | |||
| // form | |||
| import { useFormContext, Controller } from "react-hook-form"; | |||
| // @mui | |||
| import { | |||
| Checkbox, | |||
| FormControlLabel, | |||
| FormGroup, | |||
| FormControlLabelProps, | |||
| } 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 }) => { | |||
| const onSelected = (option: string) => | |||
| field.value.includes(option) | |||
| ? field.value.filter((value: string) => value !== option) | |||
| : [...field.value, option]; | |||
| return ( | |||
| <FormGroup> | |||
| {options.map((option) => ( | |||
| <FormControlLabel | |||
| key={option.value} | |||
| control={ | |||
| <Checkbox | |||
| checked={field.value.includes(option.value)} | |||
| onChange={() => field.onChange(onSelected(option.value))} | |||
| /> | |||
| } | |||
| label={option.label} | |||
| {...other} | |||
| /> | |||
| ))} | |||
| </FormGroup> | |||
| ); | |||
| }} | |||
| /> | |||
| ); | |||
| } | |||
| @@ -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,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,128 @@ | |||
| import { HasChildren } from "@types"; | |||
| import { ResultCode } from "api"; | |||
| import { login as APILogin, logout as APILogout, me } from "api/auth"; | |||
| import useAPICall from "hooks/useAPICall"; | |||
| import { createContext, memo, useEffect, useMemo, useState } from "react"; | |||
| type Auth = { | |||
| initialized: boolean; | |||
| authenticated: boolean; | |||
| userId: string | null; | |||
| name: string; | |||
| email: string; | |||
| login: (email: string, password: string) => Promise<boolean>; | |||
| logout: VoidFunction; | |||
| }; | |||
| export const AuthContext = createContext<Auth>({ | |||
| initialized: false, | |||
| authenticated: false, | |||
| userId: null, | |||
| name: "", | |||
| email: "", | |||
| login: async (email: string, password: string) => false, | |||
| logout: () => {}, | |||
| }); | |||
| type Props = HasChildren; | |||
| function AuthContextProvider({ children }: Props) { | |||
| const [initialized, setInitialized] = useState(false); | |||
| const [userId, setUserId] = useState<string | null>(null); | |||
| const [name, setName] = useState(""); | |||
| const [email, setEmail] = useState(""); | |||
| const testLogin = () => { | |||
| //TODO MOCK対応 | |||
| setInitialized(true); | |||
| setUserId("testing"); | |||
| setName("testuser"); | |||
| setEmail("test@test.com"); | |||
| }; | |||
| const authenticated = useMemo(() => { | |||
| // return !!userId; | |||
| return true; | |||
| }, [userId]); | |||
| const { callAPI: callMe } = useAPICall({ | |||
| apiMethod: me, | |||
| backDrop: true, | |||
| onSuccess: (res) => { | |||
| setInitialized(true); | |||
| //削除予定 | |||
| testLogin(); | |||
| }, | |||
| onFailed: () => { | |||
| clear(); | |||
| setInitialized(true); | |||
| //削除予定 | |||
| testLogin(); | |||
| }, | |||
| }); | |||
| const { callAPI: callLogin } = useAPICall({ | |||
| apiMethod: APILogin, | |||
| backDrop: true, | |||
| onSuccess: (res) => { | |||
| setInitialized(true); | |||
| }, | |||
| }); | |||
| const { callAPI: callLogout } = useAPICall({ | |||
| apiMethod: APILogout, | |||
| onSuccess: () => { | |||
| clear(); | |||
| }, | |||
| }); | |||
| const clear = () => { | |||
| setUserId(null); | |||
| setName(""); | |||
| setEmail(""); | |||
| }; | |||
| const login = async (email: string, password: string) => { | |||
| //削除予定 | |||
| testLogin(); | |||
| return true; | |||
| // const res = await callLogin({ email, password }); | |||
| // if (!res) return false; | |||
| // return res.result === ResultCode.SUCCESS; | |||
| }; | |||
| const logout = () => { | |||
| callLogout({}); | |||
| console.info("ログアウト"); | |||
| }; | |||
| useEffect(() => { | |||
| callMe({}); | |||
| }, []); | |||
| return ( | |||
| <AuthContext.Provider | |||
| value={{ | |||
| // Value | |||
| initialized, | |||
| authenticated, | |||
| userId, | |||
| name, | |||
| email, | |||
| // Func | |||
| login, | |||
| logout, | |||
| }} | |||
| > | |||
| {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,59 @@ | |||
| import { SettingsPhoneTwoTone } from "@mui/icons-material"; | |||
| 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,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,59 @@ | |||
| import { useLocation, useNavigate } from "react-router"; | |||
| import { Dictionary } from "@types"; | |||
| import { PageID } from "pages"; | |||
| import { getPath } from "routes/path"; | |||
| 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); | |||
| } | |||
| } | |||
| }; | |||
| 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,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,93 @@ | |||
| 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"; | |||
| 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 [mobileOpen, setMobileOpen] = useState(false); | |||
| const { drawerWidth, innerWidth, contentsWidth } = useDashboard(); | |||
| const handleDrawerToggle = () => { | |||
| setMobileOpen(!mobileOpen); | |||
| }; | |||
| console.log("へい"); | |||
| if (!initialized) { | |||
| return null; | |||
| } | |||
| if (!authenticated) { | |||
| navigateWhenChanged(getPath(PageID.PAGE_403)); | |||
| } | |||
| 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,219 @@ | |||
| import { ExpandLess, ExpandMore } from "@mui/icons-material"; | |||
| import HomeIcon from "@mui/icons-material/Home"; | |||
| import PeopleIcon from "@mui/icons-material/People"; | |||
| import ArticleIcon from "@mui/icons-material/Article"; | |||
| import SettingsIcon from "@mui/icons-material/Settings"; | |||
| import AccountBoxIcon from "@mui/icons-material/AccountBox"; | |||
| import AccountCircleIcon from "@mui/icons-material/AccountCircle"; | |||
| import AccountBalanceIcon from "@mui/icons-material/AccountBalance"; | |||
| 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 { PageID } from "pages"; | |||
| import useAuth from "hooks/useAuth"; | |||
| import useNavigateCustom from "hooks/useNavigateCustom"; | |||
| import usePage from "hooks/usePage"; | |||
| 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 { userId, name } = useAuth(); | |||
| const categories: Group[] = [ | |||
| { | |||
| label: "管理", | |||
| children: [], | |||
| }, | |||
| { | |||
| label: "アカウント", | |||
| children: [ | |||
| { label: "ログアウト", icon: <SettingsIcon />, id: PageID.LOGOUT }, | |||
| ], | |||
| }, | |||
| ]; | |||
| return ( | |||
| <Drawer variant="permanent" {...other}> | |||
| <List disablePadding> | |||
| <ListItem | |||
| sx={{ ...item, ...itemCategory, fontSize: 22, color: "#fff" }} | |||
| > | |||
| MyPage | |||
| </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 { label, children } = group; | |||
| const elements = children.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 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,81 @@ | |||
| import { yupResolver } from "@hookform/resolvers/yup"; | |||
| import { LoadingButton } from "@mui/lab"; | |||
| import { Box, Stack, Typography } from "@mui/material"; | |||
| import { PageID } from "pages"; | |||
| 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 { useEffect, useState } from "react"; | |||
| import { useForm } from "react-hook-form"; | |||
| import { getPath } from "routes/path"; | |||
| import { StoreId, setStore } from "storage/localstorage"; | |||
| 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 [message, setMessage] = useState(""); | |||
| const [sending, setSending] = useState(false); | |||
| const { 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("ログイン成功"); | |||
| navigateWhenChanged(getPath(PageID.DASHBOARD_OVERVIEW)); | |||
| } else { | |||
| error("ログイン失敗"); | |||
| setMessage("入力情報を確認してください"); | |||
| } | |||
| }; | |||
| 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,21 @@ | |||
| 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"; | |||
| export default function Page404() { | |||
| const { navigateWhenChanged } = useNavigateCustom(); | |||
| const { authenticated } = useAuth(); | |||
| // ログインページにアクセス経験ある場合は、ログインページへ遷移させる | |||
| useEffect(() => { | |||
| if (authenticated) { | |||
| navigateWhenChanged(getPath(PageID.DASHBOARD_OVERVIEW)); | |||
| return; | |||
| } | |||
| }, []); | |||
| return <Box>NotFound.</Box>; | |||
| } | |||
| @@ -0,0 +1,17 @@ | |||
| import { Box } from "@mui/material"; | |||
| import { PageID, TabID } from "pages"; | |||
| import useDashboard from "hooks/useDashBoard"; | |||
| import { useEffect } from "react"; | |||
| export default function Overview() { | |||
| const { setHeaderTitle, setTabs } = useDashboard( | |||
| PageID.DASHBOARD_OVERVIEW, | |||
| TabID.NONE | |||
| ); | |||
| useEffect(() => { | |||
| setHeaderTitle("Dashboard"); | |||
| setTabs(null); | |||
| }, []); | |||
| return <Box sx={{ p: 1, m: 1 }}></Box>; | |||
| } | |||
| @@ -0,0 +1,22 @@ | |||
| let id = 0; | |||
| export const PageID = { | |||
| NONE: id++, | |||
| LOGIN: id++, | |||
| LOGOUT: id++, | |||
| DASHBOARD_OVERVIEW: 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,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(); | |||
| } | |||
| }, []); | |||
| 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,80 @@ | |||
| import { PageID } from "pages"; | |||
| import LoadingScreen from "components/LoadingScreen"; | |||
| import useAuth from "hooks/useAuth"; | |||
| import DashboardLayout from "layouts/dashbord"; | |||
| import SimpleLayout from "layouts/simple"; | |||
| import { ElementType, Suspense, lazy, useMemo } from "react"; | |||
| import { RouteObject, useRoutes } from "react-router-dom"; | |||
| import { getRoute } from "./path"; | |||
| 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 DashboardRoutes = (): RouteObject => { | |||
| const children = [ | |||
| { | |||
| path: getRoute(PageID.DASHBOARD_OVERVIEW), | |||
| element: <Dashboard />, | |||
| }, | |||
| ]; | |||
| return { | |||
| element: <DashboardLayout />, | |||
| children, | |||
| }; | |||
| }; | |||
| export function Routes() { | |||
| const { initialized } = useAuth(); | |||
| return useRoutes([ | |||
| CommonRoutes(), | |||
| AuthRoutes(), | |||
| 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"))); | |||
| //ダッシュボード ---------------------------- | |||
| const Dashboard = Loadable(lazy(() => import("pages/dashboard"))); | |||
| // その他 --------------------------------- | |||
| const Page403 = Loadable(lazy(() => import("pages/common/Page403"))); | |||
| const Page404 = Loadable(lazy(() => import("pages/common/Page404"))); | |||
| @@ -0,0 +1,96 @@ | |||
| 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 = { | |||
| [makePathKey(PageID.NONE)]: "/", | |||
| // 認証 | |||
| [makePathKey(PageID.LOGIN)]: "/login", | |||
| [makePathKey(PageID.LOGOUT)]: "/logout", | |||
| [makePathKey(PageID.DASHBOARD_OVERVIEW)]: "/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,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,16 @@ | |||
| let id = 0; | |||
| export const StoreId = {} 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); | |||
| @@ -0,0 +1,20 @@ | |||
| import axios from "axios"; | |||
| // config | |||
| import { HOST_API } from "config"; | |||
| // ---------------------------------------------------------------------- | |||
| const axiosInstance = axios.create({ | |||
| baseURL: HOST_API, | |||
| withCredentials: true, | |||
| }); | |||
| axiosInstance.interceptors.response.use( | |||
| (response) => response, | |||
| (error) => | |||
| Promise.reject( | |||
| (error.response && error.response.data) || "Something went wrong" | |||
| ) | |||
| ); | |||
| export default axiosInstance; | |||
| @@ -0,0 +1,54 @@ | |||
| import { format, isValid, parse, parseISO } from "date-fns"; | |||
| export const DEFAULT_DATE_FORMAT = "yyyy/MM/dd"; | |||
| export const DEFAULT_DATE_TIME_FORMAT = "yyyy/MM/dd HH:mm:ss"; | |||
| export const DEFAULT_DATE_TIME_FORMAT_ANOTHER1 = "yyyy-MM-dd HH:mm:ss"; | |||
| export const DEFAULT_YYYYMM_FORMAT = "yyyyMM"; | |||
| type Input = Date | string | null | undefined; | |||
| export const formatDateStr = (source: Input) => { | |||
| return formatToStr(source, DEFAULT_DATE_FORMAT); | |||
| }; | |||
| export const formatDateTimeStr = (source: Date | string | null | undefined) => { | |||
| return formatToStr(source, DEFAULT_DATE_TIME_FORMAT); | |||
| }; | |||
| export const formatYYYYMMStr = (source: Date | string | null | undefined) => { | |||
| return formatToStr(source, DEFAULT_YYYYMM_FORMAT); | |||
| }; | |||
| const formatToStr = (source: Input, formatStr: string) => { | |||
| if (source === null || source === undefined) return ""; | |||
| if (source instanceof Date) return format(source, formatStr); | |||
| return format(parseISO(source), formatStr); | |||
| }; | |||
| export const now = () => { | |||
| return new Date(); | |||
| }; | |||
| export const nowStr = (): string => { | |||
| return formatDateTimeStr(now()); | |||
| }; | |||
| export const dateParse = (source: Input): Date | null => { | |||
| return parseFromFormat(source, DEFAULT_DATE_FORMAT); | |||
| }; | |||
| export const dateTimeParse = (source: Input): Date | null => { | |||
| return ( | |||
| parseFromFormat(source, DEFAULT_DATE_TIME_FORMAT) ?? | |||
| parseFromFormat(source, DEFAULT_DATE_TIME_FORMAT_ANOTHER1) | |||
| ); | |||
| }; | |||
| const parseFromFormat = (source: Input, format: string): Date | null => { | |||
| if (source === null || source === undefined) return null; | |||
| if (source instanceof Date) return source; | |||
| const ret = parse(source, format, new Date()); | |||
| if (isValid(ret)) { | |||
| return ret; | |||
| } | |||
| return null; | |||
| }; | |||
| @@ -0,0 +1,3 @@ | |||
| export const scrollToTop = () => { | |||
| window.scroll({ top: 0, behavior: "smooth" }); | |||
| }; | |||
| @@ -0,0 +1,11 @@ | |||
| export const TAX_RATE_DEFAULT = 0.1; | |||
| /** | |||
| * 内税計算 | |||
| */ | |||
| export function calcInnerTax( | |||
| totalAmount: number, | |||
| rate: number = TAX_RATE_DEFAULT | |||
| ) { | |||
| return Math.floor((totalAmount * rate) / (1 + rate)); | |||
| } | |||
| @@ -0,0 +1,27 @@ | |||
| { | |||
| "compilerOptions": { | |||
| "target": "ES2022", | |||
| "lib": [ | |||
| "dom", | |||
| "dom.iterable", | |||
| "esnext" | |||
| ], | |||
| "allowJs": true, | |||
| "skipLibCheck": true, | |||
| "esModuleInterop": true, | |||
| "allowSyntheticDefaultImports": true, | |||
| "strict": true, | |||
| "forceConsistentCasingInFileNames": true, | |||
| "noFallthroughCasesInSwitch": true, | |||
| "module": "esnext", | |||
| "moduleResolution": "node", | |||
| "resolveJsonModule": true, | |||
| "isolatedModules": true, | |||
| "noEmit": true, | |||
| "jsx": "react-jsx", | |||
| "baseUrl": "src" | |||
| }, | |||
| "include": [ | |||
| "src" | |||
| ] | |||
| } | |||