瀏覽代碼

初回整備

develop
sosuke.iwabuchi 2 年之前
當前提交
3e8c31c8b6
共有 91 個文件被更改,包括 14414 次插入0 次删除
  1. +20
    -0
      .env.example
  2. +23
    -0
      .gitignore
  3. +46
    -0
      README.md
  4. +63
    -0
      package.json
  5. +59
    -0
      public/.htaccess
  6. 二進制
      public/favicon.ico
  7. +22
    -0
      public/index.html
  8. 二進制
      public/logo192.png
  9. 二進制
      public/logo512.png
  10. +25
    -0
      public/manifest.json
  11. +3
    -0
      public/robots.txt
  12. +11
    -0
      src/@types/index.ts
  13. +38
    -0
      src/App.css
  14. +9
    -0
      src/App.test.tsx
  15. +38
    -0
      src/App.tsx
  16. +42
    -0
      src/api/auth.ts
  17. +255
    -0
      src/api/index.ts
  18. +30
    -0
      src/api/url.ts
  19. +67
    -0
      src/api/zipcode.ts
  20. +59
    -0
      src/codes/prefcode.ts
  21. +5
    -0
      src/components/LoadingScreen.tsx
  22. +10
    -0
      src/components/chip/RequireChip.tsx
  23. +83
    -0
      src/components/form/CheckBoxCustom.tsx
  24. +45
    -0
      src/components/form/DatePickerCustom.tsx
  25. +56
    -0
      src/components/form/InputAlert.tsx
  26. +95
    -0
      src/components/form/TextFieldCustom.tsx
  27. +36
    -0
      src/components/form/TextFieldEx.tsx
  28. +24
    -0
      src/components/hook-form/FormProvider.tsx
  29. +121
    -0
      src/components/hook-form/RHFAutoComplete.tsx
  30. +128
    -0
      src/components/hook-form/RHFCheckbox.tsx
  31. +81
    -0
      src/components/hook-form/RHFDatePicker.tsx
  32. +53
    -0
      src/components/hook-form/RHFRadioGroup.tsx
  33. +221
    -0
      src/components/hook-form/RHFSelect.tsx
  34. +29
    -0
      src/components/hook-form/RHFSwitch.tsx
  35. +66
    -0
      src/components/hook-form/RHFTextField.tsx
  36. +6
    -0
      src/components/hook-form/ex/RHFPrefCodeSelect.tsx
  37. +33
    -0
      src/components/hook-form/index.ts
  38. +18
    -0
      src/components/stack/StackRow.tsx
  39. +107
    -0
      src/components/table/TableHeadCustom.tsx
  40. +61
    -0
      src/components/table/index.tsx
  41. +13
    -0
      src/config.ts
  42. +128
    -0
      src/contexts/AuthContext.tsx
  43. +35
    -0
      src/contexts/BackDropContext.tsx
  44. +85
    -0
      src/contexts/DashboardLayoutContext.tsx
  45. +59
    -0
      src/contexts/PageContext.tsx
  46. +135
    -0
      src/contexts/SearchConditionContext.tsx
  47. +31
    -0
      src/contexts/WindowSizeContext.tsx
  48. +186
    -0
      src/hooks/useAPICall.ts
  49. +7
    -0
      src/hooks/useAuth.ts
  50. +6
    -0
      src/hooks/useBackDrop.ts
  51. +18
    -0
      src/hooks/useDashBoard.ts
  52. +86
    -0
      src/hooks/useDialog.tsx
  53. +59
    -0
      src/hooks/useNavigateCustom.ts
  54. +6
    -0
      src/hooks/usePage.ts
  55. +39
    -0
      src/hooks/useResponsive.ts
  56. +6
    -0
      src/hooks/useSearchConditionContext.ts
  57. +26
    -0
      src/hooks/useSnackbarCustom.ts
  58. +179
    -0
      src/hooks/useTable.ts
  59. +48
    -0
      src/hooks/useURLSearchParams.ts
  60. +6
    -0
      src/hooks/useWindowSize.ts
  61. +13
    -0
      src/index.css
  62. +21
    -0
      src/index.tsx
  63. +112
    -0
      src/layouts/dashbord/header.tsx
  64. +93
    -0
      src/layouts/dashbord/index.tsx
  65. +219
    -0
      src/layouts/dashbord/navigator.tsx
  66. +31
    -0
      src/layouts/dashbord/tab/ContractTabs.tsx
  67. +17
    -0
      src/layouts/dashbord/tab/index.tsx
  68. +34
    -0
      src/layouts/dashbord/tab/tabutil.tsx
  69. +26
    -0
      src/layouts/simple/index.tsx
  70. +1
    -0
      src/logo.svg
  71. +81
    -0
      src/pages/auth/login.tsx
  72. +21
    -0
      src/pages/auth/logout.tsx
  73. +5
    -0
      src/pages/common/Page403.tsx
  74. +21
    -0
      src/pages/common/Page404.tsx
  75. +17
    -0
      src/pages/dashboard/index.tsx
  76. +22
    -0
      src/pages/index.ts
  77. +17
    -0
      src/providers/CsrfTokenProvider.tsx
  78. +9
    -0
      src/providers/SnackbarProvider.tsx
  79. +1
    -0
      src/react-app-env.d.ts
  80. +15
    -0
      src/reportWebVitals.ts
  81. +80
    -0
      src/routes/index.tsx
  82. +96
    -0
      src/routes/path.ts
  83. +5
    -0
      src/setupTests.ts
  84. +16
    -0
      src/storage/localstorage/index.ts
  85. +201
    -0
      src/theme/index.tsx
  86. +20
    -0
      src/utils/axios.ts
  87. +54
    -0
      src/utils/datetime.ts
  88. +3
    -0
      src/utils/page.ts
  89. +11
    -0
      src/utils/tax.ts
  90. +27
    -0
      tsconfig.json
  91. +9879
    -0
      yarn.lock

+ 20
- 0
.env.example 查看文件

@@ -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=





+ 23
- 0
.gitignore 查看文件

@@ -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*

+ 46
- 0
README.md 查看文件

@@ -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/).

+ 63
- 0
package.json 查看文件

@@ -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"
]
}
}

+ 59
- 0
public/.htaccess 查看文件

@@ -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>

二進制
public/favicon.ico 查看文件

Before After

+ 22
- 0
public/index.html 查看文件

@@ -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>

二進制
public/logo192.png 查看文件

Before After
Width: 192  |  Height: 192  |  Size: 5.2KB

二進制
public/logo512.png 查看文件

Before After
Width: 512  |  Height: 512  |  Size: 9.4KB

+ 25
- 0
public/manifest.json 查看文件

@@ -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"
}

+ 3
- 0
public/robots.txt 查看文件

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

+ 11
- 0
src/@types/index.ts 查看文件

@@ -0,0 +1,11 @@
import { ReactNode } from "react";

export type DataUrl = string;

export type HasChildren = {
children: ReactNode;
};

export type Dictionary = {
[key: string]: string;
};

+ 38
- 0
src/App.css 查看文件

@@ -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);
}
}

+ 9
- 0
src/App.test.tsx 查看文件

@@ -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();
});

+ 38
- 0
src/App.tsx 查看文件

@@ -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>
);
}

+ 42
- 0
src/api/auth.ts 查看文件

@@ -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;
};

+ 255
- 0
src/api/index.ts 查看文件

@@ -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;
}
}

+ 30
- 0
src/api/url.ts 查看文件

@@ -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);
};

+ 67
- 0
src/api/zipcode.ts 查看文件

@@ -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;
}

+ 59
- 0
src/codes/prefcode.ts 查看文件

@@ -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 ?? "-";
}

+ 5
- 0
src/components/LoadingScreen.tsx 查看文件

@@ -0,0 +1,5 @@
import { Box } from "@mui/material";

export default function LoadingScreen() {
return <Box>Loading...</Box>;
}

+ 10
- 0
src/components/chip/RequireChip.tsx 查看文件

@@ -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} />;
}

+ 83
- 0
src/components/form/CheckBoxCustom.tsx 查看文件

@@ -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}
/>
);
}

+ 45
- 0
src/components/form/DatePickerCustom.tsx 查看文件

@@ -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);

+ 56
- 0
src/components/form/InputAlert.tsx 查看文件

@@ -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);

+ 95
- 0
src/components/form/TextFieldCustom.tsx 查看文件

@@ -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}
/>
);
}

+ 36
- 0
src/components/form/TextFieldEx.tsx 查看文件

@@ -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);

+ 24
- 0
src/components/hook-form/FormProvider.tsx 查看文件

@@ -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>
);
}

+ 121
- 0
src/components/hook-form/RHFAutoComplete.tsx 查看文件

@@ -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,
},
}}
/>
)}
/>
);
}

+ 128
- 0
src/components/hook-form/RHFCheckbox.tsx 查看文件

@@ -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>
);
}}
/>
);
}

+ 81
- 0
src/components/hook-form/RHFDatePicker.tsx 查看文件

@@ -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);

+ 53
- 0
src/components/hook-form/RHFRadioGroup.tsx 查看文件

@@ -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>
)}
/>
);
}

+ 221
- 0
src/components/hook-form/RHFSelect.tsx 查看文件

@@ -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>
)}
/>
);
}

+ 29
- 0
src/components/hook-form/RHFSwitch.tsx 查看文件

@@ -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}
/>
);
}

+ 66
- 0
src/components/hook-form/RHFTextField.tsx 查看文件

@@ -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}
/>
)}
/>
);
}

+ 6
- 0
src/components/hook-form/ex/RHFPrefCodeSelect.tsx 查看文件

@@ -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} />;
}

+ 33
- 0
src/components/hook-form/index.ts 查看文件

@@ -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;
}

+ 18
- 0
src/components/stack/StackRow.tsx 查看文件

@@ -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>
);
}

+ 107
- 0
src/components/table/TableHeadCustom.tsx 查看文件

@@ -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>
);
}

+ 61
- 0
src/components/table/index.tsx 查看文件

@@ -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>
);
};

+ 13
- 0
src/config.ts 查看文件

@@ -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 || "";

+ 128
- 0
src/contexts/AuthContext.tsx 查看文件

@@ -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);

+ 35
- 0
src/contexts/BackDropContext.tsx 查看文件

@@ -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>
);
}

+ 85
- 0
src/contexts/DashboardLayoutContext.tsx 查看文件

@@ -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>
);
}

+ 59
- 0
src/contexts/PageContext.tsx 查看文件

@@ -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>
);
}

+ 135
- 0
src/contexts/SearchConditionContext.tsx 查看文件

@@ -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>
);
}

+ 31
- 0
src/contexts/WindowSizeContext.tsx 查看文件

@@ -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>
);
}

+ 186
- 0
src/hooks/useAPICall.ts 查看文件

@@ -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,
};
}

+ 7
- 0
src/hooks/useAuth.ts 查看文件

@@ -0,0 +1,7 @@
import { AuthContext } from "contexts/AuthContext";
import { useContext } from "react";

export default function useAuth() {
const context = useContext(AuthContext);
return context;
}

+ 6
- 0
src/hooks/useBackDrop.ts 查看文件

@@ -0,0 +1,6 @@
import { useContext } from "react";
import { BackDroptContext } from "contexts/BackDropContext";

export default function useBackDrop() {
return useContext(BackDroptContext);
}

+ 18
- 0
src/hooks/useDashBoard.ts 查看文件

@@ -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;
}

+ 86
- 0
src/hooks/useDialog.tsx 查看文件

@@ -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,
};
}

+ 59
- 0
src/hooks/useNavigateCustom.ts 查看文件

@@ -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 };
}

+ 6
- 0
src/hooks/usePage.ts 查看文件

@@ -0,0 +1,6 @@
import { PageContext } from "contexts/PageContext";
import { useContext } from "react";

export default function usePage() {
return useContext(PageContext);
}

+ 39
- 0
src/hooks/useResponsive.ts 查看文件

@@ -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;
}
}

+ 6
- 0
src/hooks/useSearchConditionContext.ts 查看文件

@@ -0,0 +1,6 @@
import { useContext } from "react";
import { SearchConditionContext } from "contexts/SearchConditionContext";

export default function useSearchConditionContext() {
return useContext(SearchConditionContext);
}

+ 26
- 0
src/hooks/useSnackbarCustom.ts 查看文件

@@ -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,
};
}

+ 179
- 0
src/hooks/useTable.ts 查看文件

@@ -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,
};
}

+ 48
- 0
src/hooks/useURLSearchParams.ts 查看文件

@@ -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,
};
}

+ 6
- 0
src/hooks/useWindowSize.ts 查看文件

@@ -0,0 +1,6 @@
import { useContext } from "react";
import { WindowSizeContext } from "contexts/WindowSizeContext";

export default function useWindowSize() {
return useContext(WindowSizeContext);
}

+ 13
- 0
src/index.css 查看文件

@@ -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;
}

+ 21
- 0
src/index.tsx 查看文件

@@ -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();

+ 112
- 0
src/layouts/dashbord/header.tsx 查看文件

@@ -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>
);
}

+ 93
- 0
src/layouts/dashbord/index.tsx 查看文件

@@ -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>
);
}

+ 219
- 0
src/layouts/dashbord/navigator.tsx 查看文件

@@ -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,
};
}

+ 31
- 0
src/layouts/dashbord/tab/ContractTabs.tsx 查看文件

@@ -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>
);
}

+ 17
- 0
src/layouts/dashbord/tab/index.tsx 查看文件

@@ -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} />;
}

+ 34
- 0
src/layouts/dashbord/tab/tabutil.tsx 查看文件

@@ -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,
};
}

+ 26
- 0
src/layouts/simple/index.tsx 查看文件

@@ -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>
);
}

+ 1
- 0
src/logo.svg 查看文件

@@ -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>

+ 81
- 0
src/pages/auth/login.tsx 查看文件

@@ -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>
);
}

+ 21
- 0
src/pages/auth/logout.tsx 查看文件

@@ -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;
}

+ 5
- 0
src/pages/common/Page403.tsx 查看文件

@@ -0,0 +1,5 @@
import { Box } from "@mui/material";

export default function Page403() {
return <Box>Un Authenticated.</Box>;
}

+ 21
- 0
src/pages/common/Page404.tsx 查看文件

@@ -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>;
}

+ 17
- 0
src/pages/dashboard/index.tsx 查看文件

@@ -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>;
}

+ 22
- 0
src/pages/index.ts 查看文件

@@ -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];

+ 17
- 0
src/providers/CsrfTokenProvider.tsx 查看文件

@@ -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);

+ 9
- 0
src/providers/SnackbarProvider.tsx 查看文件

@@ -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>
);
}

+ 1
- 0
src/react-app-env.d.ts 查看文件

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

+ 15
- 0
src/reportWebVitals.ts 查看文件

@@ -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;

+ 80
- 0
src/routes/index.tsx 查看文件

@@ -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")));

+ 96
- 0
src/routes/path.ts 查看文件

@@ -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)
);
}

+ 5
- 0
src/setupTests.ts 查看文件

@@ -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';

+ 16
- 0
src/storage/localstorage/index.ts 查看文件

@@ -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();
};

+ 201
- 0
src/theme/index.tsx 查看文件

@@ -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);

+ 20
- 0
src/utils/axios.ts 查看文件

@@ -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;

+ 54
- 0
src/utils/datetime.ts 查看文件

@@ -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;
};

+ 3
- 0
src/utils/page.ts 查看文件

@@ -0,0 +1,3 @@
export const scrollToTop = () => {
window.scroll({ top: 0, behavior: "smooth" });
};

+ 11
- 0
src/utils/tax.ts 查看文件

@@ -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));
}

+ 27
- 0
tsconfig.json 查看文件

@@ -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"
]
}

+ 9879
- 0
yarn.lock
文件差異過大導致無法顯示
查看文件


Loading…
取消
儲存