Browse Source

初回整備

develop
sosuke.iwabuchi 2 years ago
commit
3e8c31c8b6
91 changed files with 14414 additions and 0 deletions
  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. BIN
      public/favicon.ico
  7. +22
    -0
      public/index.html
  8. BIN
      public/logo192.png
  9. BIN
      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 View File

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

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

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

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

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

BIN
public/favicon.ico View File

Before After

+ 22
- 0
public/index.html View File

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

BIN
public/logo192.png View File

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

BIN
public/logo512.png View File

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

+ 25
- 0
public/manifest.json View File

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

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

+ 11
- 0
src/@types/index.ts View File

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

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

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

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

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

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

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

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

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

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

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

+ 10
- 0
src/components/chip/RequireChip.tsx View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

+ 15
- 0
src/reportWebVitals.ts View File

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

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

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

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

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

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

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

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

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

+ 11
- 0
src/utils/tax.ts View File

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

@@ -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
File diff suppressed because it is too large
View File


Loading…
Cancel
Save