Initial commit
1
cope2n-fe/.dockerignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
/node_modules
|
3
cope2n-fe/.env.development
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
VITE_PORT=8080
|
||||||
|
VITE_PROXY=https://107.120.133.22/
|
||||||
|
VITE_KUBEFLOW_HOST=https://107.120.133.22:8085
|
14
cope2n-fe/.eslintrc
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"es6": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"react-app",
|
||||||
|
"prettier",
|
||||||
|
"plugin:@tanstack/eslint-plugin-query/recommended"
|
||||||
|
],
|
||||||
|
"ignorePatterns": [
|
||||||
|
"**/components/react-via/**/*.js"
|
||||||
|
]
|
||||||
|
}
|
44
cope2n-fe/.gitignore
vendored
@ -1,27 +1,33 @@
|
|||||||
# Logs
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
logs
|
|
||||||
*.log
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
/dist
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
yarn-debug.log*
|
yarn-debug.log*
|
||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
|
# rollup-plugin-visualizer
|
||||||
|
stats.html
|
||||||
|
|
||||||
|
# Ignore all the installed packages
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
||||||
# linguijs locales
|
# linguijs locales
|
||||||
src/locales/**/*.ts
|
src/locales/**/*.ts
|
||||||
|
@ -1,20 +0,0 @@
|
|||||||
stages:
|
|
||||||
- check
|
|
||||||
|
|
||||||
sonarqube-check:
|
|
||||||
stage: check
|
|
||||||
image:
|
|
||||||
name: sonarsource/sonar-scanner-cli:latest
|
|
||||||
entrypoint: ['']
|
|
||||||
variables:
|
|
||||||
SONAR_USER_HOME: '${CI_PROJECT_DIR}/.sonar' # Defines the location of the analysis task cache
|
|
||||||
GIT_DEPTH: '0' # Tells git to fetch all the branches of the project, required by the analysis task
|
|
||||||
# cache:
|
|
||||||
# key: '${CI_JOB_NAME}'
|
|
||||||
# paths:
|
|
||||||
# - .sonar/cache
|
|
||||||
script:
|
|
||||||
- sonar-scanner
|
|
||||||
allow_failure: true
|
|
||||||
only:
|
|
||||||
- develop
|
|
@ -1,4 +1,11 @@
|
|||||||
|
.*/
|
||||||
|
./dist/
|
||||||
|
./data/
|
||||||
|
3rdparty/
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
keys/
|
||||||
logs/
|
logs/
|
||||||
static/
|
static/
|
||||||
|
templates/
|
||||||
|
src/components/react-via/js/
|
||||||
|
src/components/react-via/styles/
|
||||||
|
@ -1,9 +1,29 @@
|
|||||||
{
|
{
|
||||||
|
"arrowParens": "always",
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"embeddedLanguageFormatting": "auto",
|
||||||
|
"htmlWhitespaceSensitivity": "css",
|
||||||
|
"insertPragma": false,
|
||||||
|
"jsxBracketSameLine": false,
|
||||||
|
"jsxSingleQuote": true,
|
||||||
"printWidth": 80,
|
"printWidth": 80,
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"quoteProps": "as-needed",
|
||||||
|
"requirePragma": false,
|
||||||
"semi": true,
|
"semi": true,
|
||||||
"singleQuote": true,
|
"singleQuote": true,
|
||||||
"tabWidth": 2,
|
"tabWidth": 2,
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
"endOfLine": "crlf",
|
"useTabs": false,
|
||||||
"jsxSingleQuote": false
|
"vueIndentScriptAndStyle": false,
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.json", "*.yml", "*.yaml", "*.md"],
|
||||||
|
"options": {
|
||||||
|
"tabWidth": 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"endOfLine": "lf"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,34 +0,0 @@
|
|||||||
###################
|
|
||||||
# BUILD FOR LOCAL DEVELOPMENT
|
|
||||||
###################
|
|
||||||
FROM node:16-alpine AS development
|
|
||||||
WORKDIR /app/
|
|
||||||
COPY --chown=node:node package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY --chown=node:node . .
|
|
||||||
USER node
|
|
||||||
|
|
||||||
###################
|
|
||||||
# BUILD FOR PRODUCTION
|
|
||||||
###################
|
|
||||||
FROM node:16-alpine AS build
|
|
||||||
WORKDIR /app/
|
|
||||||
ENV NODE_ENV production
|
|
||||||
COPY --chown=node:node package*.json ./
|
|
||||||
COPY --chown=node:node --from=development /app/node_modules ./node_modules
|
|
||||||
COPY --chown=node:node . .
|
|
||||||
RUN npm run build
|
|
||||||
RUN npm ci --only=production && npm cache clean --force
|
|
||||||
USER node
|
|
||||||
|
|
||||||
###################
|
|
||||||
# PRODUCTION
|
|
||||||
###################
|
|
||||||
FROM nginx:stable-alpine AS nginx
|
|
||||||
|
|
||||||
COPY --from=build /app/dist/ /usr/share/nginx/html/
|
|
||||||
COPY --from=build /app/run.sh /app/
|
|
||||||
COPY --from=build /app/nginx.conf /configs/
|
|
||||||
RUN chmod +x /app/run.sh
|
|
||||||
|
|
||||||
CMD ["/app/run.sh"]
|
|
@ -1,35 +0,0 @@
|
|||||||
###################
|
|
||||||
# BUILD FOR LOCAL DEVELOPMENT
|
|
||||||
###################
|
|
||||||
FROM node:16-alpine AS development
|
|
||||||
WORKDIR /app/
|
|
||||||
COPY --chown=node:node package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY --chown=node:node . .
|
|
||||||
USER node
|
|
||||||
|
|
||||||
###################
|
|
||||||
# BUILD FOR PRODUCTION
|
|
||||||
###################
|
|
||||||
FROM node:16-alpine AS build
|
|
||||||
WORKDIR /app/
|
|
||||||
COPY --chown=node:node package*.json ./
|
|
||||||
COPY --chown=node:node --from=development /app/node_modules ./node_modules
|
|
||||||
COPY --chown=node:node . .
|
|
||||||
RUN npm run build
|
|
||||||
ENV NODE_ENV production
|
|
||||||
RUN npm ci --only=production && npm cache clean --force
|
|
||||||
USER node
|
|
||||||
|
|
||||||
###################
|
|
||||||
# PRODUCTION
|
|
||||||
###################
|
|
||||||
FROM nginx:stable-alpine AS nginx
|
|
||||||
|
|
||||||
ARG PORT=9999
|
|
||||||
|
|
||||||
COPY --from=build /app/dist/ /usr/share/nginx/html/
|
|
||||||
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
EXPOSE ${PORT}
|
|
||||||
CMD ["nginx", "-g", "daemon off;" ]
|
|
@ -1,27 +0,0 @@
|
|||||||
# Variables
|
|
||||||
IMAGE_NAME="demoap-fe:latest"
|
|
||||||
CONTAINER_NAME="demoap-fe"
|
|
||||||
PORT="9999"
|
|
||||||
HTTP_PROXY="http://42.96.40.255:8002" # http:\/\/0.0.0.0:8002
|
|
||||||
|
|
||||||
# Make sure that HTTP_PROXY is not empty
|
|
||||||
if [ -z "$HTTP_PROXY" ]
|
|
||||||
then
|
|
||||||
echo "HTTP_PROXY is empty, you have to specify it in deploy.sh file"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Replace #proxy_server in nginx.conf with HTTP_PROXY using sed command
|
|
||||||
sed -i "s|#proxy_server|$HTTP_PROXY|g" ./nginx.conf
|
|
||||||
|
|
||||||
# Replace #port in nginx.conf with PORT using sed command
|
|
||||||
sed -i "s|#port|$PORT|g" ./nginx.conf
|
|
||||||
|
|
||||||
# Build image
|
|
||||||
docker build --build-arg PORT=$PORT --pull --rm -f "Dockerfile" -t $IMAGE_NAME "."
|
|
||||||
|
|
||||||
# Remove exist container
|
|
||||||
docker container stop $CONTAINER_NAME
|
|
||||||
|
|
||||||
# Run container from new image
|
|
||||||
docker run --rm -d -p $PORT:$PORT/tcp --name $CONTAINER_NAME $IMAGE_NAME
|
|
@ -1,12 +1,27 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
|
||||||
<title>OCR</title>
|
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta name="description" content="A ML-Ops platform" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<title>SBT</title>
|
||||||
|
<style>
|
||||||
|
div#root {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/index.tsx"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
@ -1,61 +0,0 @@
|
|||||||
server {
|
|
||||||
# listen {{port}};
|
|
||||||
# listen [::]:{{port}};
|
|
||||||
server_name localhost;
|
|
||||||
client_max_body_size 100M;
|
|
||||||
|
|
||||||
#access_log /var/log/nginx/host.access.log main;
|
|
||||||
|
|
||||||
location ~ ^/api {
|
|
||||||
proxy_pass {{proxy_server}};
|
|
||||||
proxy_read_timeout 300;
|
|
||||||
proxy_connect_timeout 300;
|
|
||||||
proxy_send_timeout 300;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /static/drf_spectacular_sidecar/ {
|
|
||||||
alias /backend-static/drf_spectacular_sidecar/;
|
|
||||||
}
|
|
||||||
|
|
||||||
location / {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html index.htm;
|
|
||||||
try_files $uri /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/static/drf_spectacular_sidecar/swagger-ui-dist {
|
|
||||||
proxy_pass {{proxy_server}};
|
|
||||||
}
|
|
||||||
|
|
||||||
#error_page 404 /404.html;
|
|
||||||
|
|
||||||
# redirect server error pages to the static page /50x.html
|
|
||||||
#
|
|
||||||
error_page 500 502 503 504 /50x.html;
|
|
||||||
location = /50x.html {
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
}
|
|
||||||
|
|
||||||
# proxy the PHP scripts to Apache listening on 127.0.0.1:80
|
|
||||||
#
|
|
||||||
#location ~ \.php$ {
|
|
||||||
# proxy_pass http://127.0.0.1;
|
|
||||||
#}
|
|
||||||
|
|
||||||
# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
|
|
||||||
#
|
|
||||||
#location ~ \.php$ {
|
|
||||||
# root html;
|
|
||||||
# fastcgi_pass 127.0.0.1:9000;
|
|
||||||
# fastcgi_index index.php;
|
|
||||||
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
|
|
||||||
# include fastcgi_params;
|
|
||||||
#}
|
|
||||||
|
|
||||||
# deny access to .htaccess files, if Apache's document root
|
|
||||||
# concurs with nginx's one
|
|
||||||
#
|
|
||||||
#location ~ /\.ht {
|
|
||||||
# deny all;
|
|
||||||
#}
|
|
||||||
}
|
|
9726
cope2n-fe/package-lock.json
generated
@ -1,57 +1,88 @@
|
|||||||
{
|
{
|
||||||
"name": "vite-project",
|
"name": "sbt-ui",
|
||||||
"private": true,
|
"version": "0.1.0",
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "npm run extract && npm run compile && vite",
|
"start": "npm run extract && npm run compile && vite",
|
||||||
"build": "npm run extract && npm run compile && tsc && vite build",
|
"build": "npm run extract && npm run compile && tsc && vite build",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"extract": "lingui extract --clean",
|
"extract": "lingui extract --clean",
|
||||||
"compile": "lingui compile",
|
"compile": "lingui compile",
|
||||||
"format": "prettier --config ./.prettierrc --write src/**/*.{ts,tsx,js,jsx}"
|
"format": "prettier --config ./.prettierrc --write src/**/*.{ts,tsx,js,jsx}",
|
||||||
|
"lint": "eslint . --ext .ts,.tsx --fix"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/colors": "^7.0.0",
|
"@ant-design/colors": "^6.0.0",
|
||||||
"@ant-design/icons": "^4.8.0",
|
"@ant-design/icons": "^4.8.0",
|
||||||
|
"@ant-design/plots": "^1.2.3",
|
||||||
|
"@ant-design/pro-layout": "^7.10.3",
|
||||||
|
"@babel/core": "^7.13.10",
|
||||||
|
"@casl/ability": "^6.3.3",
|
||||||
|
"@casl/react": "^3.1.0",
|
||||||
|
"@faker-js/faker": "^8.3.1",
|
||||||
|
"@heartexlabs/label-studio": "1.4.0",
|
||||||
"@tanstack/react-query": "^4.20.4",
|
"@tanstack/react-query": "^4.20.4",
|
||||||
"antd": "^5.1.2",
|
"antd": "^5.4.0",
|
||||||
"axios": "^1.2.2",
|
"axios": "^1.2.2",
|
||||||
"konva": "^8.3.14",
|
"chart.js": "^4.4.1",
|
||||||
|
"dagre": "^0.8.5",
|
||||||
|
"faker": "^6.6.6",
|
||||||
|
"history": "^5.3.0",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"pdfjs-dist": "^3.4.120",
|
"mousetrap": "^1.6.5",
|
||||||
"prop-types": "^15.8.1",
|
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-csv": "^2.2.2",
|
"react-chartjs-2": "^5.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-json-view-lite": "^0.9.6",
|
|
||||||
"react-konva": "^18.2.3",
|
|
||||||
"react-konva-utils": "^0.3.1",
|
|
||||||
"react-router-dom": "^6.6.1",
|
"react-router-dom": "^6.6.1",
|
||||||
|
"reactflow": "^11.4.0",
|
||||||
"styled-components": "^5.3.6",
|
"styled-components": "^5.3.6",
|
||||||
"usehooks-ts": "^2.9.1",
|
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/plugin-syntax-jsx": "^7.18.6",
|
"@babel/plugin-syntax-jsx": "^7.12.13",
|
||||||
|
"@babel/plugin-transform-react-jsx-self": "^7.12.13",
|
||||||
|
"@babel/plugin-transform-react-jsx-source": "^7.12.13",
|
||||||
"@babel/preset-typescript": "^7.18.6",
|
"@babel/preset-typescript": "^7.18.6",
|
||||||
"@babel/runtime": "^7.20.13",
|
"@babel/runtime": "^7.13.10",
|
||||||
"@lingui/cli": "^3.17.0",
|
"@lingui/cli": "^3.7.2",
|
||||||
"@lingui/core": "^3.17.0",
|
"@lingui/core": "^3.7.2",
|
||||||
"@lingui/macro": "^3.17.0",
|
"@lingui/macro": "^3.7.2",
|
||||||
"@lingui/react": "^3.17.0",
|
"@lingui/react": "^3.7.2",
|
||||||
|
"@tanstack/eslint-plugin-query": "^4.29.4",
|
||||||
|
"@tanstack/react-query-devtools": "^4.20.4",
|
||||||
|
"@types/babel-plugin-macros": "^2.8.4",
|
||||||
"@types/lodash-es": "^4.17.6",
|
"@types/lodash-es": "^4.17.6",
|
||||||
"@types/react": "^18.0.26",
|
"@types/node": "^18.11.18",
|
||||||
"@types/react-dom": "^18.0.9",
|
"@types/react": "^18.0.20",
|
||||||
"@types/styled-components": "^5.1.26",
|
"@types/react-dom": "^18.0.10",
|
||||||
"@types/uuid": "^9.0.0",
|
"@types/uuid": "^9.0.1",
|
||||||
"@vitejs/plugin-react": "^3.0.0",
|
"@vitejs/plugin-react": "^3.0.0",
|
||||||
"babel-plugin-macros": "^3.1.0",
|
"babel-plugin-macros": "^3.0.1",
|
||||||
|
"eslint": "^8.40.0",
|
||||||
|
"eslint-config-prettier": "^8.8.0",
|
||||||
|
"eslint-config-react-app": "^7.0.1",
|
||||||
"make-plural": "^7.2.0",
|
"make-plural": "^7.2.0",
|
||||||
"prettier": "^2.8.3",
|
"prettier": "^2.8.1",
|
||||||
"prettier-plugin-organize-imports": "^3.2.2",
|
"prettier-plugin-organize-imports": "^3.2.1",
|
||||||
"typescript": "^4.9.3",
|
"rollup-plugin-visualizer": "^5.9.0",
|
||||||
"vite": "^4.0.0",
|
"sass": "^1.57.1",
|
||||||
"vite-tsconfig-paths": "^4.0.5"
|
"typescript": "^4.9.4",
|
||||||
|
"vite": "^4.0.3",
|
||||||
|
"vite-plugin-svgr": "^2.4.0",
|
||||||
|
"vite-tsconfig-paths": "^4.0.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 3.8 KiB |
BIN
cope2n-fe/public/favivon.png
Normal file
After Width: | Height: | Size: 6.5 KiB |
@ -1,43 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<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" />
|
|
||||||
<meta
|
|
||||||
name="description"
|
|
||||||
content="Web site created using create-react-app"
|
|
||||||
/>
|
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
|
||||||
<!--
|
|
||||||
manifest.json provides metadata used when your web app is installed on a
|
|
||||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
|
||||||
-->
|
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
|
||||||
<!--
|
|
||||||
Notice the use of %PUBLIC_URL% in the tags above.
|
|
||||||
It will be replaced with the URL of the `public` folder during the build.
|
|
||||||
Only files inside the `public` folder can be referenced from the HTML.
|
|
||||||
|
|
||||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
|
||||||
work correctly both with client-side routing and a non-root public URL.
|
|
||||||
Learn how to configure a non-root public URL by running `npm run build`.
|
|
||||||
-->
|
|
||||||
<title>React App</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
|
||||||
<div id="root"></div>
|
|
||||||
<!--
|
|
||||||
This HTML file is a template.
|
|
||||||
If you open it directly in the browser, you will see an empty page.
|
|
||||||
|
|
||||||
You can add webfonts, meta tags, or analytics to this file.
|
|
||||||
The build step will place the bundled scripts into the <body> tag.
|
|
||||||
|
|
||||||
To begin the development, run `npm start` or `yarn start`.
|
|
||||||
To create a production bundle, use `npm run build` or `yarn build`.
|
|
||||||
-->
|
|
||||||
</body>
|
|
||||||
</html>
|
|
Before Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 9.4 KiB |
@ -1,21 +1,11 @@
|
|||||||
{
|
{
|
||||||
"short_name": "React App",
|
"short_name": "sbt",
|
||||||
"name": "Create React App Sample",
|
"name": "sbt",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "favicon.ico",
|
||||||
"sizes": "64x64 32x32 24x24 16x16",
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
"type": "image/x-icon"
|
"type": "image/x-icon"
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "logo192.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "192x192"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "logo512.png",
|
|
||||||
"type": "image/png",
|
|
||||||
"sizes": "512x512"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
|
22
cope2n-fe/public/pdf.worker.min.js
vendored
@ -1,3 +0,0 @@
|
|||||||
# https://www.robotstxt.org/robotstxt.html
|
|
||||||
User-agent: *
|
|
||||||
Disallow:
|
|
BIN
cope2n-fe/public/sbt_icon.png
Normal file
After Width: | Height: | Size: 6.5 KiB |
17
cope2n-fe/react_nginx.conf
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
server {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
# Any route that doesn't exist on the server (e.g. /devices)
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
add_header Cache-Control: "no-cache, no-store, must-revalidate";
|
||||||
|
add_header Pragma: "no-cache";
|
||||||
|
add_header Expires: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
}
|
@ -1,24 +0,0 @@
|
|||||||
# Run the project COPEN-FE
|
|
||||||
|
|
||||||
- **How to clone the project**:
|
|
||||||
|
|
||||||
`git clone http://code.sdsrv.vn/c-ope2n/frontend.git`
|
|
||||||
|
|
||||||
- **Change to working directory**:
|
|
||||||
Open the downloaded project via terminal then re-direct to the `frontend` folder and switch to the `develop` branch:
|
|
||||||
|
|
||||||
`cd frontend`
|
|
||||||
|
|
||||||
`git checkout develop`
|
|
||||||
|
|
||||||
- **Install packages**: Install needed packages/dependencies for running the\*\* project (We need to manually run this every time we add a new dependency to the `package.json` file).
|
|
||||||
|
|
||||||
`npm i`
|
|
||||||
|
|
||||||
- Start project in developer mode:
|
|
||||||
|
|
||||||
`npm start`
|
|
||||||
|
|
||||||
- **Environment variables** in `.env` file:
|
|
||||||
- Development port: VITE_PORT
|
|
||||||
- Backend API: VITE_PROXY
|
|
@ -1,5 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# update port and BD proxy
|
|
||||||
sed "s#{{proxy_server}}#$VITE_PROXY#g" /configs/nginx.conf > /etc/nginx/conf.d/default.conf
|
|
||||||
# run up
|
|
||||||
nginx -g 'daemon off;'
|
|
@ -1,4 +0,0 @@
|
|||||||
sonar.projectKey=c-ope2n_frontend_AYb9EnnVvcs_Ifu-neN-
|
|
||||||
sonar.qualitygate.wait=true
|
|
||||||
# sonar.login=admin
|
|
||||||
# sonar.password=trongtai37
|
|
@ -1,31 +0,0 @@
|
|||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { RouterProvider } from 'react-router-dom';
|
|
||||||
import Internationalization from './components/internaltionalization';
|
|
||||||
import { createRouter } from './router/createRouter';
|
|
||||||
|
|
||||||
import 'antd/dist/reset.css';
|
|
||||||
import './styles/override.css';
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
|
||||||
defaultOptions: {
|
|
||||||
queries: {
|
|
||||||
cacheTime: 30_000,
|
|
||||||
staleTime: 10_000,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const router = createRouter();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<QueryClientProvider client={queryClient}>
|
|
||||||
<Internationalization>
|
|
||||||
<RouterProvider router={router} />
|
|
||||||
</Internationalization>
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
44
cope2n-fe/src/acl/ability.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import {
|
||||||
|
AbilityBuilder,
|
||||||
|
AbilityClass,
|
||||||
|
MatchConditions,
|
||||||
|
PureAbility,
|
||||||
|
} from '@casl/ability';
|
||||||
|
import { Project, User } from 'models';
|
||||||
|
import { AppAbility } from './types';
|
||||||
|
|
||||||
|
const AppAbilityClass = PureAbility as AbilityClass<AppAbility>;
|
||||||
|
|
||||||
|
export const appAbilitiy = new AppAbilityClass([], {
|
||||||
|
conditionsMatcher: (matchConditions: MatchConditions) => matchConditions,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function updateAbility(ability: AppAbility, user: User) {
|
||||||
|
const { can, cannot, rules } = new AbilityBuilder(AppAbilityClass);
|
||||||
|
|
||||||
|
if (user.is_superuser) {
|
||||||
|
can('manage', 'all');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.is_superuser === false) {
|
||||||
|
can('read', 'Project');
|
||||||
|
// @ts-ignore
|
||||||
|
can<Project>(
|
||||||
|
['manage', 'update', 'delete', 'labeling:confirm', 'training:terminate'],
|
||||||
|
'Project',
|
||||||
|
(project: Project) => {
|
||||||
|
if (Array.isArray(project.admins)) {
|
||||||
|
if (project.admins.find((item) => item.id === user.id)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
cannot('create', 'Project');
|
||||||
|
}
|
||||||
|
|
||||||
|
// update ability
|
||||||
|
ability.update(rules);
|
||||||
|
}
|
7
cope2n-fe/src/acl/context.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { createContextualCan, useAbility } from '@casl/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { appAbilitiy } from './ability';
|
||||||
|
|
||||||
|
export const AbilityContext = React.createContext(appAbilitiy);
|
||||||
|
export const Can = createContextualCan(AbilityContext.Consumer);
|
||||||
|
export const useAppAbility = () => useAbility(AbilityContext);
|
3
cope2n-fe/src/acl/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './ability';
|
||||||
|
export * from './context';
|
||||||
|
export * from './types';
|
36
cope2n-fe/src/acl/types.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { MatchConditions, PureAbility } from '@casl/ability';
|
||||||
|
import {
|
||||||
|
Category,
|
||||||
|
DataPointMetadata,
|
||||||
|
Deployment,
|
||||||
|
Device,
|
||||||
|
LogRecord,
|
||||||
|
Project,
|
||||||
|
RegisteredModel,
|
||||||
|
RegisteredProgram,
|
||||||
|
TrainingJob,
|
||||||
|
User,
|
||||||
|
} from 'models';
|
||||||
|
|
||||||
|
type InferSubjectAs<T, V extends string> = T | V;
|
||||||
|
export type AppSubjects =
|
||||||
|
| InferSubjectAs<Category, 'Category'>
|
||||||
|
| InferSubjectAs<DataPointMetadata, 'ImageMetadata'>
|
||||||
|
| InferSubjectAs<Deployment, 'Deployment'>
|
||||||
|
| InferSubjectAs<Device, 'Device'>
|
||||||
|
| InferSubjectAs<Project, 'Project'>
|
||||||
|
| InferSubjectAs<RegisteredModel, 'RegisteredModel'>
|
||||||
|
| InferSubjectAs<RegisteredProgram, 'RegisteredProgram'>
|
||||||
|
| InferSubjectAs<TrainingJob, 'TrainingJob'>
|
||||||
|
| InferSubjectAs<User, 'User'>
|
||||||
|
| InferSubjectAs<LogRecord, 'DeviceResult'>
|
||||||
|
| 'all';
|
||||||
|
|
||||||
|
export type CRUD = 'create' | 'read' | 'update' | 'delete';
|
||||||
|
export type ConfirmLabel = 'labeling:confirm';
|
||||||
|
export type TerminateJob = 'training:terminate';
|
||||||
|
|
||||||
|
export type AppActions = CRUD | ConfirmLabel | TerminateJob | 'manage';
|
||||||
|
export type Abilities = [AppActions, AppSubjects];
|
||||||
|
|
||||||
|
export type AppAbility = PureAbility<Abilities, MatchConditions>;
|
BIN
cope2n-fe/src/assets/fonts/NotoSans-Black.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-BlackItalic.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-Bold.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-BoldItalic.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-ExtraBold.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-ExtraBoldItalic.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-ExtraLight.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-ExtraLightItalic.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-Italic.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-Light.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-LightItalic.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-Medium.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-MediumItalic.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-Regular.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-SemiBold.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-SemiBoldItalic.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-Thin.ttf
Normal file
BIN
cope2n-fe/src/assets/fonts/NotoSans-ThinItalic.ttf
Normal file
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
@ -1,59 +0,0 @@
|
|||||||
import * as React from 'react';
|
|
||||||
import { Html } from 'react-konva-utils';
|
|
||||||
|
|
||||||
export interface ControlsProps {
|
|
||||||
containerRef: React.MutableRefObject<HTMLDivElement>;
|
|
||||||
hidden?: boolean;
|
|
||||||
}
|
|
||||||
const PADDING_OFFSET = 16;
|
|
||||||
|
|
||||||
export const Controls = ({
|
|
||||||
containerRef,
|
|
||||||
children,
|
|
||||||
hidden = false,
|
|
||||||
}: React.PropsWithChildren<ControlsProps>) => {
|
|
||||||
const position = useControlsPosition(containerRef);
|
|
||||||
|
|
||||||
return hidden ? null : (
|
|
||||||
<Html
|
|
||||||
divProps={{
|
|
||||||
style: {
|
|
||||||
position: 'fixed',
|
|
||||||
top: `${position.top + PADDING_OFFSET}px`,
|
|
||||||
left: `${position.left + PADDING_OFFSET}px`,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
transform={false}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Html>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function useControlsPosition(
|
|
||||||
containerRef: React.MutableRefObject<HTMLDivElement>,
|
|
||||||
) {
|
|
||||||
const [position, setPosition] = React.useState({ top: 0, left: 0 });
|
|
||||||
const observeRef = React.useRef(
|
|
||||||
new ResizeObserver((entries) => {
|
|
||||||
const container = entries[0];
|
|
||||||
if (container) {
|
|
||||||
setPosition({
|
|
||||||
top: container.target.getBoundingClientRect().top ?? 0,
|
|
||||||
left: container.target.getBoundingClientRect().left ?? 0,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (containerRef.current) {
|
|
||||||
observeRef.current.observe(containerRef.current);
|
|
||||||
}
|
|
||||||
return () => {
|
|
||||||
observeRef.current.disconnect();
|
|
||||||
};
|
|
||||||
}, [containerRef.current]);
|
|
||||||
|
|
||||||
return position;
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
import { Circle as KonvaCircle, Rect } from 'react-konva';
|
|
||||||
import { withTransform } from './shapeFactory';
|
|
||||||
|
|
||||||
export const Rectangle = withTransform(Rect);
|
|
||||||
export const Circle = withTransform(KonvaCircle);
|
|
@ -1,95 +0,0 @@
|
|||||||
import Konva from 'konva';
|
|
||||||
import React from 'react';
|
|
||||||
import { KonvaNodeComponent, Transformer } from 'react-konva';
|
|
||||||
import { TransformableShapeProps } from '../types';
|
|
||||||
|
|
||||||
export function withTransform<
|
|
||||||
TShapeComponent extends KonvaNodeComponent<Konva.Node>,
|
|
||||||
>(Shape: TShapeComponent) {
|
|
||||||
return function ({
|
|
||||||
isTransformable,
|
|
||||||
isSelected,
|
|
||||||
onSelect,
|
|
||||||
onChange,
|
|
||||||
title,
|
|
||||||
isAnchor,
|
|
||||||
...shapeProps
|
|
||||||
}: TransformableShapeProps<TShapeComponent>) {
|
|
||||||
const labelRef = React.useRef<Konva.Label>();
|
|
||||||
const shapeRef = React.useRef<Konva.Node>();
|
|
||||||
const tranformRef = React.useRef<Konva.Transformer>();
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isSelected) {
|
|
||||||
const nodes = [shapeRef.current, labelRef.current].filter(Boolean);
|
|
||||||
tranformRef.current.nodes(nodes);
|
|
||||||
tranformRef.current.getLayer().batchDraw();
|
|
||||||
}
|
|
||||||
}, [isSelected]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* @ts-ignore */}
|
|
||||||
<Shape
|
|
||||||
ref={shapeRef}
|
|
||||||
{...shapeProps}
|
|
||||||
strokeScaleEnabled={false}
|
|
||||||
draggable={isTransformable}
|
|
||||||
onClick={onSelect}
|
|
||||||
onTap={onSelect}
|
|
||||||
onDragEnd={(e) => {
|
|
||||||
onChange({
|
|
||||||
...shapeProps,
|
|
||||||
x: e.target.x(),
|
|
||||||
y: e.target.y(),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
onTransform={(event) => {
|
|
||||||
const textNode = labelRef.current;
|
|
||||||
if (textNode) {
|
|
||||||
const stageScale = event.currentTarget.getStage().scale().x;
|
|
||||||
|
|
||||||
const absScale = labelRef.current?.getAbsoluteScale() || {
|
|
||||||
x: 1,
|
|
||||||
y: 1,
|
|
||||||
};
|
|
||||||
textNode.scaleX((textNode.scaleX() / absScale.x) * stageScale);
|
|
||||||
textNode.scaleY((textNode.scaleY() / absScale.y) * stageScale);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onTransformEnd={() => {
|
|
||||||
const node = shapeRef.current;
|
|
||||||
const scaleX = node.scaleX();
|
|
||||||
const scaleY = node.scaleY();
|
|
||||||
|
|
||||||
node.scaleX(1);
|
|
||||||
node.scaleY(1);
|
|
||||||
onChange({
|
|
||||||
...shapeProps,
|
|
||||||
x: node.x(),
|
|
||||||
y: node.y(),
|
|
||||||
width: Math.max(5, node.width() * scaleX),
|
|
||||||
height: Math.max(node.height() * scaleY),
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
fill={isSelected ? shapeProps.fill : null}
|
|
||||||
/>
|
|
||||||
{isSelected && (
|
|
||||||
<Transformer
|
|
||||||
ref={tranformRef}
|
|
||||||
ignoreStroke
|
|
||||||
boundBoxFunc={(oldBox, newBox) => {
|
|
||||||
if (newBox.width < 5 || newBox.height < 5) {
|
|
||||||
return oldBox;
|
|
||||||
}
|
|
||||||
return newBox;
|
|
||||||
}}
|
|
||||||
draggable={isTransformable}
|
|
||||||
resizeEnabled={isTransformable}
|
|
||||||
rotateEnabled={isTransformable}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,107 +0,0 @@
|
|||||||
import Konva from 'konva';
|
|
||||||
import { isNumber } from 'lodash-es';
|
|
||||||
import React from 'react';
|
|
||||||
import { KonvaNodeEvents } from 'react-konva';
|
|
||||||
|
|
||||||
function standardizeRectConfig(config: Konva.RectConfig): Konva.RectConfig {
|
|
||||||
if (config.width < 0) {
|
|
||||||
config.width = Math.abs(config.width);
|
|
||||||
config.x -= config.width;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.height < 0) {
|
|
||||||
config.height = Math.abs(config.height);
|
|
||||||
config.y -= config.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureEvtWithLayer(
|
|
||||||
evt: any,
|
|
||||||
): evt is MouseEvent & { layerX: number; layerY: number } {
|
|
||||||
return (
|
|
||||||
isNumber(evt.layerX) && isNumber(evt.layerY) && evt instanceof MouseEvent
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDrawStage({
|
|
||||||
canDraw = true,
|
|
||||||
onFinish,
|
|
||||||
}: {
|
|
||||||
canDraw?: boolean;
|
|
||||||
onFinish?(config: Konva.RectConfig): void;
|
|
||||||
}) {
|
|
||||||
const [isDrawing, setDrawing] = React.useState(false);
|
|
||||||
const [rectConfig, setRectConfig] = React.useState<Konva.RectConfig>({
|
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
stroke: 'red',
|
|
||||||
fill: 'rgba(255,0,0,0.2)',
|
|
||||||
strokeWidth: 4,
|
|
||||||
visible: false,
|
|
||||||
isAnchor: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const start: KonvaNodeEvents['onMouseDown'] = React.useCallback(
|
|
||||||
(event) => {
|
|
||||||
const evt = event.evt;
|
|
||||||
if (
|
|
||||||
canDraw &&
|
|
||||||
event.target.id() === 'background-image' &&
|
|
||||||
ensureEvtWithLayer(evt)
|
|
||||||
) {
|
|
||||||
const stage = event.currentTarget.getStage();
|
|
||||||
|
|
||||||
setDrawing(true);
|
|
||||||
setRectConfig((prevConfig) => ({
|
|
||||||
...prevConfig,
|
|
||||||
x: evt.layerX / stage.scaleX(),
|
|
||||||
y: evt.layerY / stage.scaleY(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[canDraw],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handle: KonvaNodeEvents['onMouseMove'] = React.useCallback(
|
|
||||||
(event) => {
|
|
||||||
const evt = event.evt;
|
|
||||||
if (canDraw && isDrawing && ensureEvtWithLayer(evt)) {
|
|
||||||
const stage = event.currentTarget.getStage();
|
|
||||||
setRectConfig((prevConfig) => ({
|
|
||||||
...prevConfig,
|
|
||||||
width: evt.layerX / stage.scaleX() - prevConfig.x,
|
|
||||||
height: evt.layerY / stage.scaleY() - prevConfig.y,
|
|
||||||
visible: true,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[canDraw, isDrawing],
|
|
||||||
);
|
|
||||||
|
|
||||||
const finish: KonvaNodeEvents['onMouseUp'] = React.useCallback(() => {
|
|
||||||
if (canDraw && isDrawing) {
|
|
||||||
setDrawing(false);
|
|
||||||
setRectConfig((prevConfig) => ({
|
|
||||||
...prevConfig,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
visible: false,
|
|
||||||
}));
|
|
||||||
if (onFinish && rectConfig.width && rectConfig.height) {
|
|
||||||
onFinish(standardizeRectConfig(rectConfig));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [canDraw, onFinish]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
start,
|
|
||||||
handle,
|
|
||||||
finish,
|
|
||||||
isDrawing,
|
|
||||||
rect: rectConfig,
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,4 +0,0 @@
|
|||||||
export * from './draw';
|
|
||||||
export * from './scale';
|
|
||||||
export * from './store';
|
|
||||||
export * from './use-load-image';
|
|
@ -1,33 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { StageProps } from 'react-konva';
|
|
||||||
|
|
||||||
export const SCALE_FACTOR = 1.1;
|
|
||||||
export const MIN_SCALE = 1 / 5;
|
|
||||||
export const MAX_SCALE = 5;
|
|
||||||
|
|
||||||
export function useScaleStage(initScale: StageProps['scale'] = { x: 1, y: 1 }) {
|
|
||||||
const [scale, setScale] = React.useState(initScale);
|
|
||||||
|
|
||||||
const scaleIn = React.useCallback(() => {
|
|
||||||
setScale((prev) => ({
|
|
||||||
x: Math.min(prev.x * SCALE_FACTOR, MAX_SCALE),
|
|
||||||
y: Math.min(prev.y * SCALE_FACTOR, MAX_SCALE),
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scaleOut = React.useCallback(() => {
|
|
||||||
setScale((prev) => ({
|
|
||||||
x: Math.max(prev.x / SCALE_FACTOR, MIN_SCALE),
|
|
||||||
y: Math.max(prev.y / SCALE_FACTOR, MIN_SCALE),
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
scale,
|
|
||||||
canZoomIn: scale.x < MAX_SCALE,
|
|
||||||
canZoomOut: scale.x > MIN_SCALE,
|
|
||||||
scaleIn,
|
|
||||||
scaleOut,
|
|
||||||
scaleTo: setScale,
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useStoreInstance } from '../store-context';
|
|
||||||
import {
|
|
||||||
DefaultShapeData,
|
|
||||||
ShapeConfig,
|
|
||||||
StoreSelector,
|
|
||||||
StoreValue,
|
|
||||||
} from '../types';
|
|
||||||
|
|
||||||
export function useAnnotatorStore<ReturnType>(
|
|
||||||
selector: StoreSelector<ReturnType> = (internalStore: StoreValue) =>
|
|
||||||
internalStore as ReturnType,
|
|
||||||
) {
|
|
||||||
const store = useStoreInstance();
|
|
||||||
return React.useSyncExternalStore(store.subscribe, () =>
|
|
||||||
selector(store.getStore()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useShapes<TData = DefaultShapeData>() {
|
|
||||||
// @ts-ignore
|
|
||||||
return useAnnotatorStore<ShapeConfig<TData>[]>((store) => store.shapes);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSelectedShapeId() {
|
|
||||||
return useAnnotatorStore((store) => store.selectedShapeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useBackground(): string {
|
|
||||||
return useAnnotatorStore((store) => store.background);
|
|
||||||
}
|
|
@ -1,54 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface UseLoadImagesOptions {
|
|
||||||
onSuccess?(image: HTMLImageElement): void;
|
|
||||||
onError?(): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialLoadState = {
|
|
||||||
isLoading: true,
|
|
||||||
isSuccess: false,
|
|
||||||
isError: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useLoadImage(
|
|
||||||
src: string,
|
|
||||||
{ onSuccess, onError }: UseLoadImagesOptions = {},
|
|
||||||
) {
|
|
||||||
const imageRef = React.useRef<HTMLImageElement>(new window.Image());
|
|
||||||
const [loadState, setLoadState] = React.useState(initialLoadState);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setLoadState(initialLoadState);
|
|
||||||
imageRef.current.src = src;
|
|
||||||
}, [src]);
|
|
||||||
|
|
||||||
imageRef.current.addEventListener('load', () => {
|
|
||||||
setLoadState({
|
|
||||||
isLoading: false,
|
|
||||||
isError: false,
|
|
||||||
isSuccess: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (onSuccess) {
|
|
||||||
onSuccess(imageRef.current);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
imageRef.current.addEventListener('error', () => {
|
|
||||||
setLoadState({
|
|
||||||
isLoading: false,
|
|
||||||
isSuccess: false,
|
|
||||||
isError: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (onError) {
|
|
||||||
onError();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...loadState,
|
|
||||||
image: imageRef.current,
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,249 +0,0 @@
|
|||||||
import {
|
|
||||||
ExpandOutlined,
|
|
||||||
ZoomInOutlined,
|
|
||||||
ZoomOutOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { t } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Button, Empty, Grid, Space, Spin, Tooltip } from 'antd';
|
|
||||||
import Konva from 'konva';
|
|
||||||
import React from 'react';
|
|
||||||
import { Image, Layer, Rect, Stage } from 'react-konva';
|
|
||||||
import { useIsFirstRender } from 'usehooks-ts';
|
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
|
||||||
import { Controls } from './controls';
|
|
||||||
import { Rectangle } from './custom-shapes';
|
|
||||||
import {
|
|
||||||
useBackground,
|
|
||||||
useDrawStage,
|
|
||||||
useLoadImage,
|
|
||||||
useScaleStage,
|
|
||||||
useSelectedShapeId,
|
|
||||||
useShapes,
|
|
||||||
} from './hooks';
|
|
||||||
import { useStoreInstance } from './store-context';
|
|
||||||
import { ShapeConfig, ShapeTypes, StoreValue } from './types';
|
|
||||||
|
|
||||||
interface ImageAnnotatorProps {
|
|
||||||
background?: string;
|
|
||||||
shapes?: ShapeConfig[];
|
|
||||||
initialBackground?: string;
|
|
||||||
initialShapes?: ShapeConfig[];
|
|
||||||
containerWidth?: React.CSSProperties['width'];
|
|
||||||
containerHeight?: React.CSSProperties['height'];
|
|
||||||
readOnly?: boolean;
|
|
||||||
fitToView?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ImageAnnotator({
|
|
||||||
background,
|
|
||||||
shapes,
|
|
||||||
initialBackground,
|
|
||||||
initialShapes,
|
|
||||||
containerWidth = '100%',
|
|
||||||
containerHeight = '100%',
|
|
||||||
fitToView = true,
|
|
||||||
readOnly = false,
|
|
||||||
}: ImageAnnotatorProps) {
|
|
||||||
const { i18n } = useLingui();
|
|
||||||
const screens = Grid.useBreakpoint();
|
|
||||||
const containerRef = React.useRef<HTMLDivElement>();
|
|
||||||
const stageRef = React.useRef<Konva.Stage>();
|
|
||||||
const isFirstRender = useIsFirstRender();
|
|
||||||
|
|
||||||
const storeInstance = useStoreInstance();
|
|
||||||
const internalShapes = useShapes();
|
|
||||||
const internalBackground = useBackground();
|
|
||||||
const selectedShapeId = useSelectedShapeId();
|
|
||||||
const { scale, scaleIn, scaleOut, scaleTo, canZoomIn, canZoomOut } =
|
|
||||||
useScaleStage();
|
|
||||||
const { start, handle, finish, rect } = useDrawStage({
|
|
||||||
canDraw: !readOnly,
|
|
||||||
onFinish: (finalRect) => {
|
|
||||||
storeInstance.addShapes([
|
|
||||||
{
|
|
||||||
id: uuidv4(),
|
|
||||||
type: ShapeTypes.RECTANGLE,
|
|
||||||
data: {
|
|
||||||
label: 'default label',
|
|
||||||
value: '',
|
|
||||||
},
|
|
||||||
...finalRect,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const fitImageWidth = React.useCallback(
|
|
||||||
(_image: HTMLImageElement, isContainerReady = true) => {
|
|
||||||
const SCROLL_BAR_WIDTH_PX = 15;
|
|
||||||
|
|
||||||
const viewWidth =
|
|
||||||
containerRef.current.clientWidth -
|
|
||||||
(isContainerReady ? 0 : SCROLL_BAR_WIDTH_PX);
|
|
||||||
const imageWidth = _image.naturalWidth;
|
|
||||||
const newScale = viewWidth / imageWidth;
|
|
||||||
|
|
||||||
scaleTo({
|
|
||||||
x: newScale,
|
|
||||||
y: newScale,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[scaleTo],
|
|
||||||
);
|
|
||||||
|
|
||||||
const { image, isLoading } = useLoadImage(internalBackground, {
|
|
||||||
onSuccess(_image) {
|
|
||||||
if (fitToView) {
|
|
||||||
fitImageWidth(_image, false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const newStore = getStoreChangeFromDeriveProps({
|
|
||||||
shapes,
|
|
||||||
background,
|
|
||||||
});
|
|
||||||
const firstStore: Partial<StoreValue> = {};
|
|
||||||
if (initialBackground && isFirstRender) {
|
|
||||||
firstStore.background = initialBackground;
|
|
||||||
}
|
|
||||||
if (initialShapes && isFirstRender) {
|
|
||||||
firstStore.shapes = initialShapes;
|
|
||||||
}
|
|
||||||
|
|
||||||
storeInstance.setStore((prev) => ({
|
|
||||||
...prev,
|
|
||||||
...firstStore,
|
|
||||||
...newStore,
|
|
||||||
}));
|
|
||||||
}, [shapes, background, isFirstRender]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={containerRef}
|
|
||||||
style={{
|
|
||||||
width: containerWidth,
|
|
||||||
height: containerHeight,
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'auto',
|
|
||||||
border: '1px dashed #d9d9d9',
|
|
||||||
borderRadius: '8px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{internalBackground ? (
|
|
||||||
<Stage
|
|
||||||
ref={stageRef}
|
|
||||||
scale={scale}
|
|
||||||
width={image.naturalWidth * scale.x}
|
|
||||||
height={image.naturalHeight * scale.y}
|
|
||||||
onMouseDown={(event) => {
|
|
||||||
const isClickedOnEmpty = event.target.id() === 'background-image';
|
|
||||||
|
|
||||||
if (isClickedOnEmpty) {
|
|
||||||
storeInstance.setSelectedShape(null);
|
|
||||||
start(event);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseMove={handle}
|
|
||||||
onMouseUp={finish}
|
|
||||||
>
|
|
||||||
<Layer>
|
|
||||||
<Image
|
|
||||||
image={image}
|
|
||||||
width={image.naturalWidth}
|
|
||||||
height={image.naturalHeight}
|
|
||||||
id="background-image"
|
|
||||||
/>
|
|
||||||
{internalShapes.map((shape) => (
|
|
||||||
<Rectangle
|
|
||||||
{...shape}
|
|
||||||
isSelected={shape.id === selectedShapeId}
|
|
||||||
isTransformable={!readOnly}
|
|
||||||
onSelect={() => storeInstance.setSelectedShape(shape.id)}
|
|
||||||
onChange={(shapeConfig) => {
|
|
||||||
const newShapes = internalShapes.map((item) =>
|
|
||||||
item.id === shape.id
|
|
||||||
? {
|
|
||||||
...shape,
|
|
||||||
...shapeConfig,
|
|
||||||
}
|
|
||||||
: item,
|
|
||||||
);
|
|
||||||
storeInstance.setShapes(newShapes);
|
|
||||||
}}
|
|
||||||
draggable={!readOnly}
|
|
||||||
key={shape.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Rect {...rect} />
|
|
||||||
<Controls containerRef={containerRef} hidden={!screens.lg}>
|
|
||||||
<Space>
|
|
||||||
<Tooltip title={t(i18n)`Zoom out`}>
|
|
||||||
<Button
|
|
||||||
shape="circle"
|
|
||||||
icon={<ZoomOutOutlined />}
|
|
||||||
onClick={scaleOut}
|
|
||||||
disabled={!canZoomOut}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t(i18n)`Zoom in`}>
|
|
||||||
<Button
|
|
||||||
shape="circle"
|
|
||||||
icon={<ZoomInOutlined />}
|
|
||||||
onClick={scaleIn}
|
|
||||||
disabled={!canZoomIn}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip title={t(i18n)`Fit image`}>
|
|
||||||
<Button
|
|
||||||
shape="circle"
|
|
||||||
icon={<ExpandOutlined />}
|
|
||||||
onClick={() => fitImageWidth(image)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</Space>
|
|
||||||
</Controls>
|
|
||||||
</Layer>
|
|
||||||
</Stage>
|
|
||||||
) : (
|
|
||||||
<Empty
|
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
||||||
description={t(i18n)`No data, please upload an image.`}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isLoading && (
|
|
||||||
<Spin
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: '50%',
|
|
||||||
top: '50%',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStoreChangeFromDeriveProps({
|
|
||||||
background,
|
|
||||||
shapes,
|
|
||||||
}: Pick<ImageAnnotatorProps, 'background' | 'shapes'>) {
|
|
||||||
const newStore: Partial<StoreValue> = {};
|
|
||||||
if (background !== undefined) {
|
|
||||||
newStore.background = background;
|
|
||||||
}
|
|
||||||
if (shapes !== undefined) {
|
|
||||||
newStore.shapes = shapes;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newStore;
|
|
||||||
}
|
|
@ -1,23 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { AnnotatorStore } from './store';
|
|
||||||
|
|
||||||
const defaultStore = new AnnotatorStore();
|
|
||||||
|
|
||||||
const AnnotatorContext = React.createContext(defaultStore);
|
|
||||||
|
|
||||||
export const useStoreInstance = () => React.useContext(AnnotatorContext);
|
|
||||||
|
|
||||||
export function AnnotatorProvider({
|
|
||||||
store,
|
|
||||||
children,
|
|
||||||
}: React.PropsWithChildren<{
|
|
||||||
store?: AnnotatorStore;
|
|
||||||
}>) {
|
|
||||||
const storeRef = React.useRef(new AnnotatorStore());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AnnotatorContext.Provider value={store || storeRef.current}>
|
|
||||||
{children}
|
|
||||||
</AnnotatorContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,67 +0,0 @@
|
|||||||
import { SetState } from '../../models';
|
|
||||||
import { ShapeConfig, StoreValue, SubcribeHandler } from './types';
|
|
||||||
|
|
||||||
export class AnnotatorStore {
|
|
||||||
private store: StoreValue = {
|
|
||||||
background: '',
|
|
||||||
shapes: [],
|
|
||||||
selectedShapeId: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
private subscribers = new Set<SubcribeHandler>();
|
|
||||||
|
|
||||||
private notify() {
|
|
||||||
this.subscribers.forEach((handler) => handler(this.store));
|
|
||||||
}
|
|
||||||
|
|
||||||
subscribe = (handler: SubcribeHandler) => {
|
|
||||||
this.subscribers.add(handler);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
this.subscribers.delete(handler);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
addShapes(shapes: ShapeConfig[]) {
|
|
||||||
this.store.shapes = this.store.shapes.concat(shapes);
|
|
||||||
this.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
removeShapes(shapeIds: ShapeConfig['id'][]) {
|
|
||||||
this.store.shapes = this.store.shapes.filter(
|
|
||||||
(shape) => !shapeIds.includes(shape.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
setShapes(shapesHandler: SetState<ShapeConfig[]>) {
|
|
||||||
this.store.shapes =
|
|
||||||
typeof shapesHandler === 'function'
|
|
||||||
? shapesHandler(this.store.shapes)
|
|
||||||
: shapesHandler;
|
|
||||||
this.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
setBackground(background: string) {
|
|
||||||
this.store.background = background;
|
|
||||||
this.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedShape(shapeId: ShapeConfig['id'] | null) {
|
|
||||||
this.store.selectedShapeId = shapeId;
|
|
||||||
this.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
setStore(storeHandler: SetState<StoreValue>) {
|
|
||||||
this.store =
|
|
||||||
typeof storeHandler === 'function'
|
|
||||||
? storeHandler(this.store)
|
|
||||||
: storeHandler;
|
|
||||||
this.notify();
|
|
||||||
}
|
|
||||||
|
|
||||||
getStore(): StoreValue {
|
|
||||||
return this.store;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,60 +0,0 @@
|
|||||||
import Konva from 'konva';
|
|
||||||
import { KonvaNodeComponent } from 'react-konva';
|
|
||||||
|
|
||||||
// Shapes
|
|
||||||
export enum ShapeTypes {
|
|
||||||
RECTANGLE = 'reactangle',
|
|
||||||
CIRCLE = 'circle',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DefaultShapeData {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WithCustomConfig<
|
|
||||||
TConfig extends Konva.ShapeConfig,
|
|
||||||
TData = DefaultShapeData,
|
|
||||||
> = TConfig & {
|
|
||||||
id: string;
|
|
||||||
data?: TData;
|
|
||||||
} & WithTransformProps<TConfig>;
|
|
||||||
|
|
||||||
export type RectangleConfig<TData = DefaultShapeData> = {
|
|
||||||
type: ShapeTypes.RECTANGLE;
|
|
||||||
} & WithCustomConfig<Konva.RectConfig, TData>;
|
|
||||||
|
|
||||||
export type CircleConfig<TData = DefaultShapeData> = {
|
|
||||||
type: ShapeTypes.CIRCLE;
|
|
||||||
} & WithCustomConfig<Konva.CircleConfig, TData>;
|
|
||||||
|
|
||||||
export type ShapeConfig<TData = DefaultShapeData> =
|
|
||||||
| RectangleConfig<TData>
|
|
||||||
| CircleConfig<TData>;
|
|
||||||
|
|
||||||
// Store
|
|
||||||
export type SubcribeHandler = (store: StoreValue) => void;
|
|
||||||
export type StoreSelector<ReturnType> = (store: StoreValue) => ReturnType;
|
|
||||||
export interface StoreValue {
|
|
||||||
background: string;
|
|
||||||
shapes: ShapeConfig[];
|
|
||||||
selectedShapeId: ShapeConfig['id'] | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transformable
|
|
||||||
export type TransformableShapeProps<
|
|
||||||
TShapeComponent extends KonvaNodeComponent<Konva.Node>,
|
|
||||||
> = TShapeComponent extends KonvaNodeComponent<Konva.Node, infer Props>
|
|
||||||
? Props & WithTransformProps<Props>
|
|
||||||
: never;
|
|
||||||
|
|
||||||
export type WithTransformProps<
|
|
||||||
TShapeConfig extends Konva.ShapeConfig = Konva.ShapeConfig,
|
|
||||||
> = {
|
|
||||||
isTransformable?: boolean;
|
|
||||||
isSelected?: boolean;
|
|
||||||
onSelect?(): void;
|
|
||||||
onChange?(shapeConfig: TShapeConfig): void;
|
|
||||||
title?: string;
|
|
||||||
isAnchor?: boolean;
|
|
||||||
};
|
|
@ -1,33 +0,0 @@
|
|||||||
import { Typography } from "antd";
|
|
||||||
|
|
||||||
export const Brand = ({
|
|
||||||
collapsed,
|
|
||||||
isBordered = true,
|
|
||||||
}: {
|
|
||||||
collapsed: boolean;
|
|
||||||
isBordered?: boolean;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<Typography.Title
|
|
||||||
ellipsis={{
|
|
||||||
rows: 1,
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
marginTop: 3,
|
|
||||||
marginLeft: 12,
|
|
||||||
marginRight: 12,
|
|
||||||
paddingTop: 18,
|
|
||||||
paddingBottom: 18,
|
|
||||||
letterSpacing: 2,
|
|
||||||
borderBottomLeftRadius: 10,
|
|
||||||
borderBottomRightRadius: 10,
|
|
||||||
color: "#fff",
|
|
||||||
textAlign: "center",
|
|
||||||
backgroundColor: "rgb(0, 106, 255)",
|
|
||||||
fontSize: 32,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{collapsed ? "O" : "OCR"}
|
|
||||||
</Typography.Title>
|
|
||||||
);
|
|
||||||
};
|
|
2
cope2n-fe/src/components/charts/index.tsx
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { default as MultiTypeChart } from './multitype-chart';
|
||||||
|
export { default as PieChart } from './pie-chart';
|
41
cope2n-fe/src/components/charts/multitype-chart.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import {
|
||||||
|
BarController,
|
||||||
|
BarElement,
|
||||||
|
CategoryScale,
|
||||||
|
Chart as ChartJS,
|
||||||
|
ChartData,
|
||||||
|
ChartOptions,
|
||||||
|
ChartTypeRegistry,
|
||||||
|
Legend,
|
||||||
|
LinearScale,
|
||||||
|
LineController,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
Tooltip,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Chart } from 'react-chartjs-2';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
LinearScale,
|
||||||
|
CategoryScale,
|
||||||
|
BarElement,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Legend,
|
||||||
|
Tooltip,
|
||||||
|
LineController,
|
||||||
|
BarController,
|
||||||
|
);
|
||||||
|
|
||||||
|
interface MultiChartProps {
|
||||||
|
type: keyof ChartTypeRegistry;
|
||||||
|
data: ChartData;
|
||||||
|
options?: ChartOptions;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MultiTypeChart(props: MultiChartProps) {
|
||||||
|
const { type, data, options, height } = props;
|
||||||
|
|
||||||
|
return <Chart type={type} data={data} height={height} options={options} />;
|
||||||
|
}
|
20
cope2n-fe/src/components/charts/pie-chart.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import {
|
||||||
|
ArcElement,
|
||||||
|
Chart as ChartJS,
|
||||||
|
ChartData,
|
||||||
|
ChartOptions,
|
||||||
|
Legend,
|
||||||
|
Tooltip,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Pie } from 'react-chartjs-2';
|
||||||
|
|
||||||
|
ChartJS.register(ArcElement, Tooltip, Legend);
|
||||||
|
|
||||||
|
interface PieChartProps {
|
||||||
|
data: ChartData<'pie'>;
|
||||||
|
options?: ChartOptions<'pie'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PieChart({ data, options }: PieChartProps) {
|
||||||
|
return <Pie data={data} options={options} />;
|
||||||
|
}
|
16
cope2n-fe/src/components/collapse/index.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Collapse as AntCollapse, CollapseProps, theme } from 'antd';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
function Collapse(props: CollapseProps) {
|
||||||
|
return <AntCollapse {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Panel = styled(AntCollapse.Panel)`
|
||||||
|
& .sbt-collapse-header:hover {
|
||||||
|
color: ${theme.defaultConfig.token.colorPrimary};
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
Collapse.Panel = Panel;
|
||||||
|
|
||||||
|
export { Collapse };
|
13
cope2n-fe/src/components/display-none/index.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface DisplayNoneProps {
|
||||||
|
enabled?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DisplayNone: React.FC<DisplayNoneProps> = ({
|
||||||
|
enabled = false,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
return <div style={{ display: enabled ? 'none' : '' }}>{children}</div>;
|
||||||
|
};
|
@ -1,11 +0,0 @@
|
|||||||
import { Typography } from 'antd';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
|
|
||||||
export const EditableCell = styled(Typography.Text)`
|
|
||||||
margin-top: 0 !important;
|
|
||||||
margin-bottom: 0 !important;
|
|
||||||
|
|
||||||
& > .ant-typography-copy {
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
`;
|
|
@ -1,28 +0,0 @@
|
|||||||
import { Typography } from 'antd';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export type EllipsisTitleProps = React.ComponentProps<typeof Typography.Title>;
|
|
||||||
|
|
||||||
export const EllipsisTitle = (props: EllipsisTitleProps) => {
|
|
||||||
const [isEllipsis, setEllipsis] = React.useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Typography.Title
|
|
||||||
{...props}
|
|
||||||
ellipsis={
|
|
||||||
props.ellipsis
|
|
||||||
? {
|
|
||||||
...(typeof props.ellipsis === 'object' ? props.ellipsis : {}),
|
|
||||||
onEllipsis(value) {
|
|
||||||
setEllipsis(value);
|
|
||||||
if (typeof props.ellipsis === 'object') {
|
|
||||||
props.ellipsis.onEllipsis(value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: false
|
|
||||||
}
|
|
||||||
title={isEllipsis ? String(props.children) : ''}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
@ -1 +0,0 @@
|
|||||||
export * from './ellipsis-title';
|
|
136
cope2n-fe/src/components/error-boundary/index.tsx
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Button, Result, Typography } from 'antd';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { NavigateFunction, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface WithRouteProps {
|
||||||
|
navigate: NavigateFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withRouter<T extends WithRouteProps = WithRouteProps>(
|
||||||
|
WrappedComponent: React.ComponentType<T>,
|
||||||
|
) {
|
||||||
|
const displayName =
|
||||||
|
WrappedComponent.displayName || WrappedComponent.name || 'Component';
|
||||||
|
|
||||||
|
const ComponentWithRouter = (props: Omit<T, keyof WithRouteProps>) => {
|
||||||
|
const themeProps = { navigate: useNavigate() };
|
||||||
|
|
||||||
|
return <WrappedComponent {...themeProps} {...(props as T)} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
ComponentWithRouter.displayName = `withRouter(${displayName})`;
|
||||||
|
|
||||||
|
return ComponentWithRouter;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps extends WithRouteProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
imperativeError?: AxiosError | null;
|
||||||
|
resetImperativeError?: () => void;
|
||||||
|
}
|
||||||
|
type ErrorBoundaryState =
|
||||||
|
| { error: null; errorInfo: null }
|
||||||
|
| { error: AxiosError; errorInfo: null }
|
||||||
|
| { error: Error; errorInfo: React.ErrorInfo };
|
||||||
|
|
||||||
|
class ErrorBoundary extends React.Component<
|
||||||
|
ErrorBoundaryProps,
|
||||||
|
ErrorBoundaryState
|
||||||
|
> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.resetError = this.resetError.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
resetError() {
|
||||||
|
this.setState({
|
||||||
|
error: null,
|
||||||
|
errorInfo: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
|
||||||
|
this.setState({
|
||||||
|
error,
|
||||||
|
errorInfo,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps(nextProps: Readonly<ErrorBoundaryProps>): void {
|
||||||
|
if (nextProps.imperativeError) {
|
||||||
|
this.setState({
|
||||||
|
error: nextProps.imperativeError,
|
||||||
|
errorInfo: null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.resetError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderServerInternalError() {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status='500'
|
||||||
|
title='500'
|
||||||
|
subTitle={t`Sorry, something went wrong.`}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
onClick={() => {
|
||||||
|
this.resetError();
|
||||||
|
this.props.navigate('/dashboard');
|
||||||
|
}}
|
||||||
|
>{t`Back to Dashboard`}</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Typography.Paragraph>
|
||||||
|
<Typography.Text strong>
|
||||||
|
{this.state.error?.toString()}
|
||||||
|
</Typography.Text>
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Typography.Paragraph>
|
||||||
|
<Typography.Text style={{ whiteSpace: 'pre-wrap' }}>
|
||||||
|
{t`Something went wrong.`}
|
||||||
|
</Typography.Text>
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Result>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderServerMaintainanceError() {
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status='500'
|
||||||
|
title='503'
|
||||||
|
subTitle={t`Service temporarily unavailable.`}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
onClick={this.props.resetImperativeError}
|
||||||
|
>{t`Retry`}</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(): React.ReactNode {
|
||||||
|
if (this.state.error instanceof AxiosError) {
|
||||||
|
return this.renderServerMaintainanceError();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.error) {
|
||||||
|
return this.renderServerInternalError();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default withRouter(ErrorBoundary);
|
47
cope2n-fe/src/components/form-blocker/form-blocker.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { ExclamationCircleOutlined } from '@ant-design/icons';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Modal } from 'antd';
|
||||||
|
import { ANT_PREFIX_CLASS } from 'consts';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { unstable_useBlocker as useBlocker } from 'react-router-dom';
|
||||||
|
|
||||||
|
function FormBlocker({ block }) {
|
||||||
|
const [isBlocking, setIsBlocking] = useState(false);
|
||||||
|
const [hasShownModal, setHasShownModal] = useState(false); // prevent multiple modals from showing
|
||||||
|
const nextLocationRef = useRef(null);
|
||||||
|
|
||||||
|
useBlocker((transition) => {
|
||||||
|
if (block) {
|
||||||
|
setIsBlocking(true);
|
||||||
|
nextLocationRef.current = transition.nextLocation;
|
||||||
|
setHasShownModal(false);
|
||||||
|
|
||||||
|
return !isBlocking;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isBlocking && !hasShownModal) {
|
||||||
|
setHasShownModal(true);
|
||||||
|
Modal.confirm({
|
||||||
|
title: t`You have unsaved changes!`,
|
||||||
|
content: t`Are you sure you want to leave without saving?`,
|
||||||
|
icon: <ExclamationCircleOutlined />,
|
||||||
|
closable: true,
|
||||||
|
prefixCls: ANT_PREFIX_CLASS,
|
||||||
|
onOk() {
|
||||||
|
setIsBlocking(true);
|
||||||
|
window.location.href = nextLocationRef.current.pathname;
|
||||||
|
},
|
||||||
|
onCancel() {
|
||||||
|
setIsBlocking(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isBlocking, nextLocationRef, hasShownModal]);
|
||||||
|
|
||||||
|
return <div key={block as unknown as React.Key}></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FormBlocker;
|
42
cope2n-fe/src/components/global-hotkeys/index.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import Mousetrap from 'mousetrap';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
export interface KeyMapItem {
|
||||||
|
name: string;
|
||||||
|
sequences: string[];
|
||||||
|
action: 'keydown' | 'keyup' | 'keypress';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KeyMap = Record<string, KeyMapItem>;
|
||||||
|
|
||||||
|
export type Handlers<TKeyMap extends KeyMap> = {
|
||||||
|
[key in keyof TKeyMap]: (event: KeyboardEvent, shortcut: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props<TKeyMap extends KeyMap> {
|
||||||
|
children?: JSX.Element;
|
||||||
|
keyMap: TKeyMap;
|
||||||
|
handlers: Handlers<TKeyMap>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GlobalHotKeys<TKeyMap extends KeyMap>(
|
||||||
|
props: Props<TKeyMap>,
|
||||||
|
): JSX.Element {
|
||||||
|
const { children, keyMap, handlers } = props;
|
||||||
|
useEffect(() => {
|
||||||
|
for (const key of Object.keys(keyMap)) {
|
||||||
|
const { sequences, action } = keyMap[key];
|
||||||
|
const handler = handlers[key];
|
||||||
|
Mousetrap.bind(sequences, handler, action);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (const key of Object.keys(keyMap)) {
|
||||||
|
const { sequences, action } = keyMap[key];
|
||||||
|
Mousetrap.unbind(sequences, action);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return children || <></>;
|
||||||
|
}
|
@ -1,50 +1,84 @@
|
|||||||
import { i18n } from '@lingui/core';
|
import { i18n } from '@lingui/core';
|
||||||
import { I18nProvider } from '@lingui/react';
|
import { I18nProvider } from '@lingui/react';
|
||||||
import { ConfigProvider } from 'antd';
|
import { ConfigProvider } from 'antd';
|
||||||
import { Locale } from 'antd/es/locale-provider';
|
import { Locale } from 'antd/es/locale';
|
||||||
import enUS from 'antd/es/locale/en_US';
|
import enUS from 'antd/es/locale/en_US';
|
||||||
import viVN from 'antd/es/locale/vi_VN';
|
import viVN from 'antd/es/locale/vi_VN';
|
||||||
|
import { ANT_PREFIX_CLASS } from 'consts';
|
||||||
|
import 'dayjs/locale/en';
|
||||||
|
import 'dayjs/locale/vi';
|
||||||
|
import { dynamicActivate, getLocale } from 'i18n';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useLocalStorage } from 'usehooks-ts';
|
import { colors, shapes, typography } from 'theme/opus';
|
||||||
import { LOCALE_KEY } from '../../consts';
|
import { useGlobalState, useLocalStorage } from 'utils/hooks';
|
||||||
import { dynamicActivate, getLocale } from '../../i18n';
|
|
||||||
|
|
||||||
function enhanceAntdViLocale(locale: Locale): Locale {
|
|
||||||
return {
|
|
||||||
...locale,
|
|
||||||
Text: {
|
|
||||||
copied: 'Đã sao chép',
|
|
||||||
copy: 'Sao chép',
|
|
||||||
edit: 'Chỉnh sửa',
|
|
||||||
expand: 'Mở rộng',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const antdLocales: Record<string, Locale> = {
|
const antdLocales: Record<string, Locale> = {
|
||||||
en: enUS,
|
en: enUS,
|
||||||
vi: enhanceAntdViLocale(viVN),
|
vi: viVN,
|
||||||
};
|
};
|
||||||
|
|
||||||
interface InternationalizationProps {
|
interface InternationalizationProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const theme: React.ComponentProps<typeof ConfigProvider>['theme'] = {
|
||||||
|
token: {
|
||||||
|
// colorPrimary: colors.primary,
|
||||||
|
colorBorder: colors.border.default,
|
||||||
|
// fontFamily: typography.fontFamily,
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
// fontSizeHeading1: typography.fontSize.h1,
|
||||||
|
// fontSizeHeading2: typography.fontSize.h2,
|
||||||
|
// fontSizeHeading3: typography.fontSize.h3,
|
||||||
|
// fontSizeHeading4: typography.fontSize.h4,
|
||||||
|
// fontSizeHeading5: typography.fontSize.h5,
|
||||||
|
fontWeightStrong: typography.fontWeight.bold,
|
||||||
|
borderRadius: shapes.borderRadius.button,
|
||||||
|
colorLink: typography.fontColor.hyperlink,
|
||||||
|
colorTextBase: typography.fontColor.default,
|
||||||
|
// lineHeight: typography.lineHeight.bodyText,
|
||||||
|
// lineHeightHeading1: typography.lineHeight.title,
|
||||||
|
// lineHeightHeading2: typography.lineHeight.title,
|
||||||
|
// lineHeightHeading3: typography.lineHeight.title,
|
||||||
|
// lineHeightHeading4: typography.lineHeight.title,
|
||||||
|
// lineHeightHeading5: typography.lineHeight.title,
|
||||||
|
},
|
||||||
|
inherit: true,
|
||||||
|
components: {
|
||||||
|
Input: {
|
||||||
|
controlHeight: 38,
|
||||||
|
},
|
||||||
|
Select: {
|
||||||
|
controlHeight: 38,
|
||||||
|
},
|
||||||
|
DatePicker: {
|
||||||
|
controlHeight: 38,
|
||||||
|
},
|
||||||
|
InputNumber: {
|
||||||
|
controlHeight: 38,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const Internationalization = ({ children }: InternationalizationProps) => {
|
const Internationalization = ({ children }: InternationalizationProps) => {
|
||||||
const [localeStorage] = useLocalStorage(LOCALE_KEY, getLocale());
|
const [localeStorage] = useLocalStorage('sbt-locale', getLocale());
|
||||||
|
const { data: locale } = useGlobalState(['sbt-locale'], localeStorage);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
dynamicActivate(localeStorage);
|
dynamicActivate(locale);
|
||||||
}, [localeStorage]);
|
}, [locale]);
|
||||||
|
|
||||||
const antdLocale = React.useMemo(
|
const antdLocale = React.useMemo(() => antdLocales[locale], [locale]);
|
||||||
() => antdLocales[localeStorage],
|
|
||||||
[localeStorage],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<I18nProvider i18n={i18n} forceRenderOnLocaleChange={false}>
|
<I18nProvider i18n={i18n} forceRenderOnLocaleChange>
|
||||||
<ConfigProvider locale={antdLocale}>{children}</ConfigProvider>
|
<ConfigProvider
|
||||||
|
locale={antdLocale}
|
||||||
|
theme={theme}
|
||||||
|
prefixCls={ANT_PREFIX_CLASS}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ConfigProvider>
|
||||||
</I18nProvider>
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1,41 +0,0 @@
|
|||||||
import { Segmented } from "antd";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import { useLocalStorage } from "usehooks-ts";
|
|
||||||
import { LOCALE_KEY } from "../../consts";
|
|
||||||
import { dynamicActivate, getLocale } from "../../i18n";
|
|
||||||
|
|
||||||
const StyledSegmented = styled(Segmented)`
|
|
||||||
& label.ant-segmented-item.ant-segmented-item-selected,
|
|
||||||
& .ant-segmented-thumb {
|
|
||||||
color: white;
|
|
||||||
background-color: #001529;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .ant-segmented-item {
|
|
||||||
transition: #001529 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const LanguageSelect = () => {
|
|
||||||
const [locale, setLocale] = useLocalStorage(LOCALE_KEY, getLocale());
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StyledSegmented
|
|
||||||
options={[
|
|
||||||
{
|
|
||||||
label: "EN",
|
|
||||||
value: "en",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "VI",
|
|
||||||
value: "vi",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
value={locale}
|
|
||||||
onChange={(value: string) => {
|
|
||||||
dynamicActivate(value);
|
|
||||||
setLocale(value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
82
cope2n-fe/src/components/left-menu/index.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
AppstoreOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
FileSearchOutlined,
|
||||||
|
QuestionCircleOutlined,
|
||||||
|
RotateRightOutlined,
|
||||||
|
UsergroupAddOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Menu, MenuProps } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
type MenuItem = Required<MenuProps>['items'][number];
|
||||||
|
|
||||||
|
function useGetMenuItem() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return function getItem(
|
||||||
|
label: React.ReactNode,
|
||||||
|
key: React.Key,
|
||||||
|
icon?: React.ReactNode,
|
||||||
|
children?: MenuItem[],
|
||||||
|
type?: 'group',
|
||||||
|
): MenuItem {
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
label,
|
||||||
|
type,
|
||||||
|
onClick({ key: clickedKey }) {
|
||||||
|
navigate(clickedKey);
|
||||||
|
},
|
||||||
|
} as MenuItem;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function LeftMenu() {
|
||||||
|
const location = useLocation();
|
||||||
|
const getItem = useGetMenuItem();
|
||||||
|
|
||||||
|
const generalSubItems = [
|
||||||
|
getItem(t`Dashboard`, '/dashboard', <AppstoreOutlined />),
|
||||||
|
getItem(t`Inference`, '/inference', <RotateRightOutlined />),
|
||||||
|
getItem(t`Reviews`, '/reviews', <FileSearchOutlined />),
|
||||||
|
getItem(t`Reports`, '/reports', <BarChartOutlined />),
|
||||||
|
getItem(t`Users`, '/users', <UsergroupAddOutlined />),
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
height: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
mode='vertical'
|
||||||
|
selectedKeys={[location.pathname]}
|
||||||
|
style={{ borderRight: 'none' }}
|
||||||
|
items={generalSubItems}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginLeft: '20px',
|
||||||
|
marginBottom: '20px',
|
||||||
|
gap: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<QuestionCircleOutlined />
|
||||||
|
<span>Help</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(LeftMenu);
|
79
cope2n-fe/src/components/left-menu/user-header.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
GlobalOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Dropdown, Menu } from 'antd';
|
||||||
|
import { useAuth } from 'hooks/useAuth';
|
||||||
|
import { DEFAULT_LOCALE } from 'i18n';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useGlobalState, useLocalStorage } from 'utils/hooks';
|
||||||
|
|
||||||
|
const UserHeader = () => {
|
||||||
|
const { user, logOut } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [, setLocaleStorage] = useLocalStorage('sbt-locale', DEFAULT_LOCALE);
|
||||||
|
const { data: locale, setData: setLocale } = useGlobalState(
|
||||||
|
['sbt-locale'],
|
||||||
|
DEFAULT_LOCALE,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMenu = (
|
||||||
|
<Menu className='sbt-header-menu' selectedKeys={[locale]}>
|
||||||
|
<Menu.SubMenu
|
||||||
|
key='language'
|
||||||
|
icon={<GlobalOutlined />}
|
||||||
|
title={<span>{t`Language`}</span>}
|
||||||
|
>
|
||||||
|
<Menu.Item
|
||||||
|
key='vi'
|
||||||
|
onClick={() => {
|
||||||
|
setLocaleStorage('vi');
|
||||||
|
setLocale('vi');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t`Vietnamese`}
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item
|
||||||
|
key='en'
|
||||||
|
onClick={() => {
|
||||||
|
setLocaleStorage('en');
|
||||||
|
setLocale('en');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t`English`}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.SubMenu>
|
||||||
|
|
||||||
|
<Menu.Item
|
||||||
|
key='logout'
|
||||||
|
icon={<LogoutOutlined />}
|
||||||
|
onClick={async () => {
|
||||||
|
await logOut();
|
||||||
|
navigate('/auth/login');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t`Logout`}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dropdown
|
||||||
|
placement='bottomRight'
|
||||||
|
overlay={userMenu}
|
||||||
|
arrow={{ pointAtCenter: true }}
|
||||||
|
>
|
||||||
|
<SettingOutlined style={{ cursor: 'pointer' }} />
|
||||||
|
</Dropdown>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UserHeader;
|
22
cope2n-fe/src/components/page-header/index.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { PageHeader, PageHeaderProps } from '@ant-design/pro-layout';
|
||||||
|
import { Typography } from 'antd';
|
||||||
|
|
||||||
|
export type SbtPageHeaderProps = Omit<PageHeaderProps, 'breadcrumb'> & {
|
||||||
|
routes?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SbtPageHeader = ({
|
||||||
|
title,
|
||||||
|
routes = [],
|
||||||
|
...remainProps
|
||||||
|
}: SbtPageHeaderProps) => {
|
||||||
|
return (
|
||||||
|
<PageHeader
|
||||||
|
title={<Typography.Title level={3}>{title}</Typography.Title>}
|
||||||
|
style={{
|
||||||
|
padding: '8px 0',
|
||||||
|
}}
|
||||||
|
{...remainProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
4
cope2n-fe/src/components/report-detail/index.tsx
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { default as ReportDetailTable } from './report-detail-table';
|
||||||
|
export { default as ReportInformation } from './report-information';
|
||||||
|
export { default as ReportMultiTypeChart } from './report-multitype-chart';
|
||||||
|
export * from './report-pie-chart';
|
319
cope2n-fe/src/components/report-detail/report-detail-table.tsx
Normal file
@ -0,0 +1,319 @@
|
|||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { Table } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface DataType {
|
||||||
|
key: React.Key;
|
||||||
|
subSidiaries: string;
|
||||||
|
extractionDate: string | Date;
|
||||||
|
snOrImeiNumber: number;
|
||||||
|
invoiceNumber: number;
|
||||||
|
totalImages: number;
|
||||||
|
successfulNumber: number;
|
||||||
|
successfulPercentage: number;
|
||||||
|
badNumber: number;
|
||||||
|
badPercentage: number;
|
||||||
|
snImeiAAR: number; // AAR: Average Accuracy Rate
|
||||||
|
purchaseDateAAR: number;
|
||||||
|
retailerNameAAR: number;
|
||||||
|
snImeiAPT: number; // APT: Average Processing Time
|
||||||
|
invoiceAPT: number;
|
||||||
|
snImeiTC: number; // TC: transaction count
|
||||||
|
invoiceTC: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: TableColumnsType<DataType> = [
|
||||||
|
{
|
||||||
|
title: 'Subsidiaries',
|
||||||
|
dataIndex: 'subSidiaries',
|
||||||
|
key: 'subSidiaries',
|
||||||
|
fixed: 'left',
|
||||||
|
width: '100px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'OCR extraction date',
|
||||||
|
dataIndex: 'extractionDate',
|
||||||
|
key: 'extractionDate',
|
||||||
|
fixed: 'left',
|
||||||
|
width: '130px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'OCR Images',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'SN/IMEI',
|
||||||
|
dataIndex: 'snOrImeiNumber',
|
||||||
|
key: 'snOrImeiNumber',
|
||||||
|
width: '50px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Invoice',
|
||||||
|
dataIndex: 'invoiceNumber',
|
||||||
|
key: 'invoiceNumber',
|
||||||
|
width: '50px',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total Images',
|
||||||
|
dataIndex: 'totalImages',
|
||||||
|
key: 'totalImages',
|
||||||
|
fixed: 'left',
|
||||||
|
width: '130px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Image Quality',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'Successful',
|
||||||
|
dataIndex: 'successfulNumber',
|
||||||
|
key: 'successfulNumber',
|
||||||
|
width: '50px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '% Successful',
|
||||||
|
dataIndex: 'successfulPercentage',
|
||||||
|
key: 'successfulPercentage',
|
||||||
|
width: '120px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Bad',
|
||||||
|
dataIndex: 'badNumber',
|
||||||
|
key: 'badNumber',
|
||||||
|
width: '30px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '% Bad',
|
||||||
|
dataIndex: 'badPercentage',
|
||||||
|
key: 'badPercentage',
|
||||||
|
width: '60px',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: 'Average accuracy rate (%) \n(※ character-based)',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'IMEI / Serial no.',
|
||||||
|
dataIndex: 'snImeiAAR',
|
||||||
|
key: 'snImeiAAR',
|
||||||
|
width: '130px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Purchase date',
|
||||||
|
dataIndex: 'purchaseDateAAR',
|
||||||
|
key: 'purchaseDateAAR',
|
||||||
|
width: '130px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Retailer name',
|
||||||
|
dataIndex: 'retailerNameAAR',
|
||||||
|
key: 'retailerNameAAR',
|
||||||
|
width: '130px',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Average Processing Time',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'SN/IMEI',
|
||||||
|
dataIndex: 'snImeiAPT',
|
||||||
|
key: 'snImeiAPT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Invoice',
|
||||||
|
dataIndex: 'invoiceAPT',
|
||||||
|
key: 'invoiceAPT',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Usage (Transaction Count)',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
title: 'SN/IMEI',
|
||||||
|
dataIndex: 'snImeiTC',
|
||||||
|
key: 'snImeiTC',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Invoice',
|
||||||
|
dataIndex: 'invoiceTC',
|
||||||
|
key: 'invoiceTC',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const data: DataType[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
data.push({
|
||||||
|
key: i,
|
||||||
|
subSidiaries: '',
|
||||||
|
extractionDate: 'SubTotal (Jan)',
|
||||||
|
snOrImeiNumber: null,
|
||||||
|
invoiceNumber: null,
|
||||||
|
totalImages: Math.floor(Math.random() * 100),
|
||||||
|
successfulNumber: Math.floor(Math.random() * 100),
|
||||||
|
successfulPercentage: Math.floor(Math.random() * 100),
|
||||||
|
badNumber: Math.floor(Math.random() * 100),
|
||||||
|
badPercentage: Math.floor(Math.random() * 100),
|
||||||
|
snImeiAAR: null,
|
||||||
|
purchaseDateAAR: null,
|
||||||
|
retailerNameAAR: null,
|
||||||
|
snImeiAPT: null,
|
||||||
|
invoiceAPT: null,
|
||||||
|
snImeiTC: Math.floor(Math.random() * 100),
|
||||||
|
invoiceTC: Math.floor(Math.random() * 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const expandedRowRender = () => {
|
||||||
|
const subData = [];
|
||||||
|
for (let i = 0; i < 5; ++i) {
|
||||||
|
subData.push({
|
||||||
|
key: i,
|
||||||
|
subSidiaries: 'SESP',
|
||||||
|
extractionDate: 'Jan',
|
||||||
|
snOrImeiNumber: Math.floor(Math.random() * 100),
|
||||||
|
invoiceNumber: Math.floor(Math.random() * 100),
|
||||||
|
totalImages: Math.floor(Math.random() * 100),
|
||||||
|
successfulNumber: Math.floor(Math.random() * 100),
|
||||||
|
successfulPercentage: Math.floor(Math.random() * 100),
|
||||||
|
badNumber: Math.floor(Math.random() * 100),
|
||||||
|
badPercentage: Math.floor(Math.random() * 100),
|
||||||
|
snImeiAAR: Math.floor(Math.random() * 100),
|
||||||
|
purchaseDateAAR: Math.floor(Math.random() * 100),
|
||||||
|
retailerNameAAR: Math.floor(Math.random() * 100),
|
||||||
|
snImeiAPT: Math.floor(Math.random() * 100),
|
||||||
|
invoiceAPT: Math.floor(Math.random() * 100),
|
||||||
|
snImeiTC: Math.floor(Math.random() * 100),
|
||||||
|
invoiceTC: Math.floor(Math.random() * 100),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subColumns: TableColumnsType<DataType> = [
|
||||||
|
{
|
||||||
|
title: 'Subsidiaries',
|
||||||
|
dataIndex: 'subSidiaries',
|
||||||
|
key: 'subSidiaries',
|
||||||
|
fixed: 'left',
|
||||||
|
width: '100px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'OCR extraction date',
|
||||||
|
dataIndex: 'extractionDate',
|
||||||
|
key: 'extractionDate',
|
||||||
|
fixed: 'left',
|
||||||
|
width: '130px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SN/IMEI',
|
||||||
|
dataIndex: 'snOrImeiNumber',
|
||||||
|
key: 'snOrImeiNumber',
|
||||||
|
width: '50px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Invoice',
|
||||||
|
dataIndex: 'invoiceNumber',
|
||||||
|
key: 'invoiceNumber',
|
||||||
|
width: '50px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Total Images',
|
||||||
|
dataIndex: 'totalImages',
|
||||||
|
key: 'totalImages',
|
||||||
|
fixed: 'left',
|
||||||
|
width: '130px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Successful',
|
||||||
|
dataIndex: 'successfulNumber',
|
||||||
|
key: 'successfulNumber',
|
||||||
|
width: '50px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '% Successful',
|
||||||
|
dataIndex: 'successfulPercentage',
|
||||||
|
key: 'successfulPercentage',
|
||||||
|
width: '120px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Bad',
|
||||||
|
dataIndex: 'badNumber',
|
||||||
|
key: 'badNumber',
|
||||||
|
width: '30px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '% Bad',
|
||||||
|
dataIndex: 'badPercentage',
|
||||||
|
key: 'badPercentage',
|
||||||
|
width: '60px',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: 'IMEI / Serial no.',
|
||||||
|
dataIndex: 'snImeiAAR',
|
||||||
|
key: 'snImeiAAR',
|
||||||
|
width: '130px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Purchase date',
|
||||||
|
dataIndex: 'purchaseDateAAR',
|
||||||
|
key: 'purchaseDateAAR',
|
||||||
|
width: '130px',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Retailer name',
|
||||||
|
dataIndex: 'retailerNameAAR',
|
||||||
|
key: 'retailerNameAAR',
|
||||||
|
width: '130px',
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: 'SN/IMEI',
|
||||||
|
dataIndex: 'snImeiAPT',
|
||||||
|
key: 'snImeiAPT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Invoice',
|
||||||
|
dataIndex: 'invoiceAPT',
|
||||||
|
key: 'invoiceAPT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SN/IMEI',
|
||||||
|
dataIndex: 'snImeiTC',
|
||||||
|
key: 'snImeiTC',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Invoice',
|
||||||
|
dataIndex: 'invoiceTC',
|
||||||
|
key: 'invoiceTC',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
columns={subColumns}
|
||||||
|
dataSource={subData}
|
||||||
|
pagination={false}
|
||||||
|
bordered
|
||||||
|
// showHeader={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ReportDetailTable: React.FC = () => (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
bordered
|
||||||
|
size='small'
|
||||||
|
expandable={{ expandedRowRender, defaultExpandedRowKeys: [0, 1] }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default ReportDetailTable;
|
@ -0,0 +1,59 @@
|
|||||||
|
import { Typography } from 'antd';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 1px solid #ccc;
|
||||||
|
padding: 8px 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
function ReportInformation() {
|
||||||
|
const data = [
|
||||||
|
{
|
||||||
|
title: 'Created date',
|
||||||
|
content: '11/11/2024',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'No. requests',
|
||||||
|
content: '150',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'No. images',
|
||||||
|
content: '200',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'No. bad quality images',
|
||||||
|
content: '5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Avg client request time',
|
||||||
|
content: '3000',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Avg OCR process time',
|
||||||
|
content: '1500',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Avg accuracy',
|
||||||
|
content: '11/11/2024',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{data.map((item) => (
|
||||||
|
<Container key={item.title}>
|
||||||
|
<Typography.Title
|
||||||
|
level={5}
|
||||||
|
style={{ marginBottom: 0, minWidth: '250px' }}
|
||||||
|
>
|
||||||
|
{item.title}
|
||||||
|
</Typography.Title>
|
||||||
|
<Typography.Text>{item.content}</Typography.Text>
|
||||||
|
</Container>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReportInformation;
|
@ -0,0 +1,39 @@
|
|||||||
|
import { MultiTypeChart } from 'components/charts';
|
||||||
|
|
||||||
|
function ReportMultiTypeChart() {
|
||||||
|
const data = {
|
||||||
|
labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
type: 'line' as const,
|
||||||
|
label: 'Dataset 1',
|
||||||
|
borderColor: 'rgb(255, 99, 132)',
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: false,
|
||||||
|
data: [50, 50, 50, 50, 50, 50, 50],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'line' as const,
|
||||||
|
label: 'Dataset 2',
|
||||||
|
backgroundColor: 'rgb(75, 192, 192)',
|
||||||
|
data: [10, 20, 30, 40, 50, 60, 70],
|
||||||
|
borderWidth: 2,
|
||||||
|
fill: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const options = {
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<MultiTypeChart type='bar' height={320} data={data} options={options} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReportMultiTypeChart;
|
26
cope2n-fe/src/components/report-detail/report-pie-chart.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { PieChart } from 'components/charts';
|
||||||
|
|
||||||
|
function ReportPieChart() {
|
||||||
|
const data = {
|
||||||
|
labels: ['Red', 'Blue', 'Yellow'],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'My First Dataset',
|
||||||
|
data: [300, 50, 100],
|
||||||
|
backgroundColor: [
|
||||||
|
'rgb(255, 99, 132)',
|
||||||
|
'rgb(54, 162, 235)',
|
||||||
|
'rgb(255, 205, 86)',
|
||||||
|
],
|
||||||
|
hoverOffset: 4,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PieChart data={data} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ReportPieChart;
|
149
cope2n-fe/src/components/report-detail/report-table.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { Button, Table } from 'antd';
|
||||||
|
import { ReportDetail } from 'models';
|
||||||
|
import { useReportList } from 'queries/report';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
const ReportTable: React.FC = () => {
|
||||||
|
const { isLoading, data: reportData } = useReportList({
|
||||||
|
page_size: 100,
|
||||||
|
});
|
||||||
|
const report_data: any = reportData;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [pagination, setPagination] = useState(() => ({
|
||||||
|
page: reportData?.page.total_pages || 1,
|
||||||
|
page_size: 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const columns: TableColumnsType<ReportDetail> = [
|
||||||
|
{
|
||||||
|
title: 'ID',
|
||||||
|
dataIndex: 'ID',
|
||||||
|
key: 'ID',
|
||||||
|
sorter: (a, b) => a.ID - b.ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Created Date',
|
||||||
|
dataIndex: 'Created Date',
|
||||||
|
key: 'Created Date',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'No. Requests',
|
||||||
|
dataIndex: 'No. Requests',
|
||||||
|
key: 'No. Requests',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Purchase Date Acc',
|
||||||
|
dataIndex: 'Purchase Date Acc',
|
||||||
|
key: 'Purchase Date Acc',
|
||||||
|
render: (_, record) => {
|
||||||
|
return (
|
||||||
|
record['Purchase Date Acc'] &&
|
||||||
|
Number(record['Purchase Date Acc']).toFixed(2)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
title: 'Retailer Acc',
|
||||||
|
dataIndex: 'Retailer Acc',
|
||||||
|
key: 'Retailer Acc',
|
||||||
|
render: (_, record) => {
|
||||||
|
return (
|
||||||
|
record['Retailer Acc'] && Number(record['Retailer Acc']).toFixed(2)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'IMEI Acc',
|
||||||
|
dataIndex: 'IMEI Acc',
|
||||||
|
key: 'IMEI Acc',
|
||||||
|
render: (_, record) => {
|
||||||
|
return record['IMEI Acc'] && Number(record['IMEI Acc']).toFixed(2);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Avg Accuracy',
|
||||||
|
dataIndex: 'Avg Accuracy',
|
||||||
|
key: 'Avg Accuracy',
|
||||||
|
render: (_, record) => {
|
||||||
|
return (
|
||||||
|
record['Avg Accuracy'] && Number(record['Avg Accuracy']).toFixed(2)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Avg Client Request Time',
|
||||||
|
dataIndex: 'Avg. Client Request Time',
|
||||||
|
key: 'Avg. Client Request Time',
|
||||||
|
render: (_, record) => {
|
||||||
|
return (
|
||||||
|
record['Avg Client Request Time'] &&
|
||||||
|
Number(record['Avg Client Request Time']).toFixed(2)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Avg. OCR Processing Time',
|
||||||
|
dataIndex: 'Avg. OCR Processing Time',
|
||||||
|
key: 'Avg. OCR Processing Time',
|
||||||
|
render: (_, record) => {
|
||||||
|
return (
|
||||||
|
record['Avg. OCR Processing Time'] &&
|
||||||
|
Number(record['Avg. OCR Processing Time']).toFixed(2)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Actions',
|
||||||
|
dataIndex: 'actions',
|
||||||
|
key: 'actions',
|
||||||
|
width: 200,
|
||||||
|
render: (_, record) => {
|
||||||
|
return (
|
||||||
|
<div style={{ flexDirection: 'row' }}>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
navigate(`/reports/${record.report_id}/detail`);
|
||||||
|
}}
|
||||||
|
style={{ marginRight: 10 }}
|
||||||
|
>
|
||||||
|
Detail
|
||||||
|
</Button>
|
||||||
|
<Button>Download</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
loading={isLoading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={(report_data && report_data?.report_detail) || []}
|
||||||
|
bordered
|
||||||
|
size='small'
|
||||||
|
pagination={{
|
||||||
|
current: pagination.page,
|
||||||
|
pageSize: pagination.page_size,
|
||||||
|
total: report_data?.count || 0,
|
||||||
|
onChange: (page, pageSize) => {
|
||||||
|
setPagination({ page, page_size: pageSize || 10 });
|
||||||
|
},
|
||||||
|
showSizeChanger: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ReportTable;
|
110
cope2n-fe/src/components/report-detail/test-table.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { DownOutlined } from '@ant-design/icons';
|
||||||
|
import type { TableColumnsType } from 'antd';
|
||||||
|
import { Badge, Dropdown, Space, Table } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface DataType {
|
||||||
|
key: React.Key;
|
||||||
|
name: string;
|
||||||
|
platform: string;
|
||||||
|
version: string;
|
||||||
|
upgradeNum: number;
|
||||||
|
creator: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpandedDataType {
|
||||||
|
key: React.Key;
|
||||||
|
date: string;
|
||||||
|
name: string;
|
||||||
|
upgradeNum: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{ key: '1', label: 'Action 1' },
|
||||||
|
{ key: '2', label: 'Action 2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TestTable: React.FC = () => {
|
||||||
|
const expandedRowRender = () => {
|
||||||
|
const columns: TableColumnsType<ExpandedDataType> = [
|
||||||
|
{ title: 'Date', dataIndex: 'date', key: 'date' },
|
||||||
|
{ title: 'Name', dataIndex: 'name', key: 'name' },
|
||||||
|
{
|
||||||
|
title: 'Status',
|
||||||
|
key: 'state',
|
||||||
|
render: () => <Badge status='success' text='Finished' />,
|
||||||
|
},
|
||||||
|
{ title: 'Upgrade Status', dataIndex: 'upgradeNum', key: 'upgradeNum' },
|
||||||
|
{
|
||||||
|
title: 'Action',
|
||||||
|
dataIndex: 'operation',
|
||||||
|
key: 'operation',
|
||||||
|
render: () => (
|
||||||
|
<Space size='middle'>
|
||||||
|
<a>Pause</a>
|
||||||
|
<a>Stop</a>
|
||||||
|
<Dropdown menu={{ items }}>
|
||||||
|
<a>
|
||||||
|
More <DownOutlined />
|
||||||
|
</a>
|
||||||
|
</Dropdown>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const data = [];
|
||||||
|
for (let i = 0; i < 3; ++i) {
|
||||||
|
data.push({
|
||||||
|
key: i.toString(),
|
||||||
|
date: '2014-12-24 23:12:00',
|
||||||
|
name: 'This is production name',
|
||||||
|
upgradeNum: 'Upgraded: 56',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data}
|
||||||
|
pagination={false}
|
||||||
|
showHeader={false}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: TableColumnsType<DataType> = [
|
||||||
|
{ title: 'Name', dataIndex: 'name', key: 'name' },
|
||||||
|
{ title: 'Platform', dataIndex: 'platform', key: 'platform' },
|
||||||
|
{ title: 'Version', dataIndex: 'version', key: 'version' },
|
||||||
|
{ title: 'Upgraded', dataIndex: 'upgradeNum', key: 'upgradeNum' },
|
||||||
|
{ title: 'Creator', dataIndex: 'creator', key: 'creator' },
|
||||||
|
{ title: 'Date', dataIndex: 'createdAt', key: 'createdAt' },
|
||||||
|
{ title: 'Action', key: 'operation', render: () => <a>Publish</a> },
|
||||||
|
];
|
||||||
|
|
||||||
|
const data: DataType[] = [];
|
||||||
|
for (let i = 0; i < 3; ++i) {
|
||||||
|
data.push({
|
||||||
|
key: i.toString(),
|
||||||
|
name: 'Screen',
|
||||||
|
platform: 'iOS',
|
||||||
|
version: '10.3.4.5654',
|
||||||
|
upgradeNum: 500,
|
||||||
|
creator: 'Jack',
|
||||||
|
createdAt: '2014-12-24 23:12:00',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
expandable={{ expandedRowRender, defaultExpandedRowKeys: ['0'] }}
|
||||||
|
dataSource={data}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TestTable;
|
@ -1,22 +0,0 @@
|
|||||||
import { t, Trans } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Button, Result } from 'antd';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
export default function () {
|
|
||||||
const { i18n } = useLingui();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Result
|
|
||||||
status="404"
|
|
||||||
title="401"
|
|
||||||
subTitle={t(i18n)`Unauthenticated.`}
|
|
||||||
extra={
|
|
||||||
<Button type="primary" onClick={() => navigate('/invoice')}>
|
|
||||||
<Trans>Try Again</Trans>
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,15 +0,0 @@
|
|||||||
import { t } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { Result } from 'antd';
|
|
||||||
|
|
||||||
export default function () {
|
|
||||||
const { i18n } = useLingui();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Result
|
|
||||||
status="500"
|
|
||||||
title="500"
|
|
||||||
subTitle={t(i18n)`Sorry, something went wrong.`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
21
cope2n-fe/src/components/results/forbidden.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Button, Result } from 'antd';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const ForbiddenResult = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status='403'
|
||||||
|
title='403'
|
||||||
|
subTitle={t`Sorry, you are not authorized to access this page.`}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
type='primary'
|
||||||
|
>{t`Back to Dashboard`}</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
2
cope2n-fe/src/components/results/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './forbidden';
|
||||||
|
export * from './not-found';
|
20
cope2n-fe/src/components/results/not-found.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { t } from '@lingui/macro';
|
||||||
|
import { Button, Result } from 'antd';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
export const NotFound = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<Result
|
||||||
|
status='404'
|
||||||
|
title='404'
|
||||||
|
subTitle={t`Sorry, the page you visited does not exist.`}
|
||||||
|
extra={
|
||||||
|
<Button
|
||||||
|
type='primary'
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
>{t`Back to Dashboard`}</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
16
cope2n-fe/src/components/spin/index.tsx
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Spin } from 'antd';
|
||||||
|
|
||||||
|
export function GlobalSpin() {
|
||||||
|
return (
|
||||||
|
<Spin
|
||||||
|
size='large'
|
||||||
|
tip='Loading ...'
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: '50%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translate(-50%, -50%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -1,6 +0,0 @@
|
|||||||
export const testingUser = {
|
|
||||||
id: 1,
|
|
||||||
maxTemplate: 3,
|
|
||||||
maxBoundingBox: 20,
|
|
||||||
uniqueComma: 'W5@X8#',
|
|
||||||
};
|
|
@ -1,6 +1,15 @@
|
|||||||
|
export const DIVERGE_FORM_ICON_COLOR = 'rgba(0, 0, 0, 0.25)';
|
||||||
export const DEFAULT_PAGE = 1;
|
export const DEFAULT_PAGE = 1;
|
||||||
export const DEFAULT_PAGE_SIZE = 10;
|
export const DEFAULT_PAGE_SIZE = 10;
|
||||||
export const TOKEN_KEY = 'cope2n-token';
|
export const DATE_FORMAT = 'YYYY-MM-DD';
|
||||||
export const LOCALE_KEY = 'cope2n-locale';
|
export const TIME_FORMAT = 'HH:mm';
|
||||||
export const DEFAULT_REFRESH_INTERVAL = 2_000;
|
export const DATE_TIME_FORMAT = `${DATE_FORMAT} ${TIME_FORMAT}`;
|
||||||
export const PRECISION = 3;
|
export const GET_ALL_SIZE = 10000; /* This is a tricky number to get all items */
|
||||||
|
export const POLLING_INTERVAL_MS = 5 * 1000;
|
||||||
|
export const DEBOUNCE_SEARCH_MS = 0.2 * 1000;
|
||||||
|
export const MIN_CHARACTERS_TO_SEARCH = 2;
|
||||||
|
export const ANT_PREFIX_CLASS = 'sbt';
|
||||||
|
export const FALLBACK_IP_ADDRESS = '107.120.133.22';
|
||||||
|
|
||||||
|
export const FALLBACK_IMAGE_BASE64 =
|
||||||
|
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMIAAADDCAYAAADQvc6UAAABRWlDQ1BJQ0MgUHJvZmlsZQAAKJFjYGASSSwoyGFhYGDIzSspCnJ3UoiIjFJgf8LAwSDCIMogwMCcmFxc4BgQ4ANUwgCjUcG3awyMIPqyLsis7PPOq3QdDFcvjV3jOD1boQVTPQrgSkktTgbSf4A4LbmgqISBgTEFyFYuLykAsTuAbJEioKOA7DkgdjqEvQHEToKwj4DVhAQ5A9k3gGyB5IxEoBmML4BsnSQk8XQkNtReEOBxcfXxUQg1Mjc0dyHgXNJBSWpFCYh2zi+oLMpMzyhRcASGUqqCZ16yno6CkYGRAQMDKMwhqj/fAIcloxgHQqxAjIHBEugw5sUIsSQpBobtQPdLciLEVJYzMPBHMDBsayhILEqEO4DxG0txmrERhM29nYGBddr//5/DGRjYNRkY/l7////39v///y4Dmn+LgeHANwDrkl1AuO+pmgAAADhlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAAqACAAQAAAABAAAAwqADAAQAAAABAAAAwwAAAAD9b/HnAAAHlklEQVR4Ae3dP3PTWBSGcbGzM6GCKqlIBRV0dHRJFarQ0eUT8LH4BnRU0NHR0UEFVdIlFRV7TzRksomPY8uykTk/zewQfKw/9znv4yvJynLv4uLiV2dBoDiBf4qP3/ARuCRABEFAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghggQAQZQKAnYEaQBAQaASKIAQJEkAEEegJmBElAoBEgghgg0Aj8i0JO4OzsrPv69Wv+hi2qPHr0qNvf39+iI97soRIh4f3z58/u7du3SXX7Xt7Z2enevHmzfQe+oSN2apSAPj09TSrb+XKI/f379+08+A0cNRE2ANkupk+ACNPvkSPcAAEibACyXUyfABGm3yNHuAECRNgAZLuYPgEirKlHu7u7XdyytGwHAd8jjNyng4OD7vnz51dbPT8/7z58+NB9+/bt6jU/TI+AGWHEnrx48eJ/EsSmHzx40L18+fLyzxF3ZVMjEyDCiEDjMYZZS5wiPXnyZFbJaxMhQIQRGzHvWR7XCyOCXsOmiDAi1HmPMMQjDpbpEiDCiL358eNHurW/5SnWdIBbXiDCiA38/Pnzrce2YyZ4//59F3ePLNMl4PbpiL2J0L979+7yDtHDhw8vtzzvdGnEXdvUigSIsCLAWavHp/+qM0BcXMd/q25n1vF57TYBp0a3mUzilePj4+7k5KSLb6gt6ydAhPUzXnoPR0dHl79WGTNCfBnn1uvSCJdegQhLI1vvCk+fPu2ePXt2tZOYEV6/fn31dz+shwAR1sP1cqvLntbEN9MxA9xcYjsxS1jWR4AIa2Ibzx0tc44fYX/16lV6NDFLXH+YL32jwiACRBiEbf5KcXoTIsQSpzXx4N28Ja4BQoK7rgXiydbHjx/P25TaQAJEGAguWy0+2Q8PD6/Ki4R8EVl+bzBOnZY95fq9rj9zAkTI2SxdidBHqG9+skdw43borCXO/ZcJdraPWdv22uIEiLA4q7nvvCug8WTqzQveOH26fodo7g6uFe/a17W3+nFBAkRYENRdb1vkkz1CH9cPsVy/jrhr27PqMYvENYNlHAIesRiBYwRy0V+8iXP8+/fvX11Mr7L7ECueb/r48eMqm7FuI2BGWDEG8cm+7G3NEOfmdcTQw4h9/55lhm7DekRYKQPZF2ArbXTAyu4kDYB2YxUzwg0gi/41ztHnfQG26HbGel/crVrm7tNY+/1btkOEAZ2M05r4FB7r9GbAIdxaZYrHdOsgJ/wCEQY0J74TmOKnbxxT9n3FgGGWWsVdowHtjt9Nnvf7yQM2aZU/TIAIAxrw6dOnAWtZZcoEnBpNuTuObWMEiLAx1HY0ZQJEmHJ3HNvGCBBhY6jtaMoEiJB0Z29vL6ls58vxPcO8/zfrdo5qvKO+d3Fx8Wu8zf1dW4p/cPzLly/dtv9Ts/EbcvGAHhHyfBIhZ6NSiIBTo0LNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiECRCjUbEPNCRAhZ6NSiAARCjXbUHMCRMjZqBQiQIRCzTbUnAARcjYqhQgQoVCzDTUnQIScjUohAkQo1GxDzQkQIWejUogAEQo121BzAkTI2agUIkCEQs021JwAEXI2KoUIEKFQsw01J0CEnI1KIQJEKNRsQ80JECFno1KIABEKNdtQcwJEyNmoFCJAhELNNtScABFyNiqFCBChULMNNSdAhJyNSiEC/wGgKKC4YMA4TAAAAABJRU5ErkJggg==';
|
||||||
|
@ -1,122 +0,0 @@
|
|||||||
import { t } from '@lingui/macro';
|
|
||||||
import { useLingui } from '@lingui/react';
|
|
||||||
import { notification, Upload, UploadFile, UploadProps } from 'antd';
|
|
||||||
import { RcFile } from 'antd/es/upload';
|
|
||||||
import React from 'react';
|
|
||||||
import styled from 'styled-components';
|
|
||||||
import {
|
|
||||||
IMAGE_SIZE_LIMIT,
|
|
||||||
isFileUnderLimit,
|
|
||||||
isImageFile,
|
|
||||||
isPDFFile,
|
|
||||||
pdf2Images,
|
|
||||||
} from '../utils/file';
|
|
||||||
import { bytesToMegabytes } from '../utils/storage';
|
|
||||||
|
|
||||||
export function useAntdLocalFiles(uploadProps: UploadProps) {
|
|
||||||
const fileUriRef = React.useRef('');
|
|
||||||
const [selectedFile, setSelectedFile] = React.useState<UploadFile | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [files, setFiles] = React.useState<UploadFile[]>([]);
|
|
||||||
const { i18n } = useLingui();
|
|
||||||
|
|
||||||
const fileValidation = (file) => {
|
|
||||||
if (!isImageFile(file) && !isPDFFile(file)) {
|
|
||||||
notification.error({
|
|
||||||
message: t(i18n)`Upload file validation`,
|
|
||||||
description: t(
|
|
||||||
i18n,
|
|
||||||
)`You are not allowed to upload file other than images (.jpg, .png, .jpeg).`,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isFileUnderLimit(file)) {
|
|
||||||
notification.error({
|
|
||||||
message: t(i18n)`Upload file validation`,
|
|
||||||
description: t(
|
|
||||||
i18n,
|
|
||||||
)`You are not allowed to upload file bigger than ${bytesToMegabytes(
|
|
||||||
IMAGE_SIZE_LIMIT,
|
|
||||||
)}MB.`,
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onChange: UploadProps['onChange'] = React.useCallback(
|
|
||||||
async ({ fileList, file }) => {
|
|
||||||
for (file of fileList) {
|
|
||||||
if (isPDFFile(file.originFileObj)) {
|
|
||||||
const imageFiles = (await pdf2Images(file.originFileObj)) as RcFile[];
|
|
||||||
file.originFileObj = imageFiles.shift();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const isValidFile = fileValidation(file);
|
|
||||||
|
|
||||||
if (isValidFile) {
|
|
||||||
if (uploadProps.maxCount === 1 && fileList.length) {
|
|
||||||
URL.revokeObjectURL(fileUriRef.current);
|
|
||||||
setSelectedFile(fileList[0]);
|
|
||||||
fileUriRef.current = URL.createObjectURL(fileList[0].originFileObj);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFiles(fileList);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[uploadProps.maxCount],
|
|
||||||
);
|
|
||||||
|
|
||||||
const onPreview: UploadProps['onPreview'] = React.useCallback((file) => {
|
|
||||||
URL.revokeObjectURL(fileUriRef.current);
|
|
||||||
fileUriRef.current = URL.createObjectURL(file.originFileObj);
|
|
||||||
setSelectedFile(file);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const onRemove: UploadProps['onRemove'] = React.useCallback(
|
|
||||||
(file) => {
|
|
||||||
if (file.uid === selectedFile?.uid) {
|
|
||||||
URL.revokeObjectURL(fileUriRef.current);
|
|
||||||
fileUriRef.current = '';
|
|
||||||
setSelectedFile(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
setFiles((prevFiles) => prevFiles.filter((f) => f.uid !== file.uid));
|
|
||||||
},
|
|
||||||
[selectedFile],
|
|
||||||
);
|
|
||||||
|
|
||||||
const reset = React.useCallback(() => {
|
|
||||||
setFiles([]);
|
|
||||||
setSelectedFile(null);
|
|
||||||
fileUriRef.current = '';
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const antdUploadProps: UploadProps = {
|
|
||||||
...uploadProps,
|
|
||||||
onChange,
|
|
||||||
onPreview,
|
|
||||||
onRemove,
|
|
||||||
fileList: files,
|
|
||||||
beforeUpload() {
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
selectedFileUri: fileUriRef.current,
|
|
||||||
selectedFile,
|
|
||||||
antdUploadProps,
|
|
||||||
fileUriRef,
|
|
||||||
reset,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StyledUpload = styled(Upload)`
|
|
||||||
& .ant-upload-list-item-actions > a {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
`;
|
|
@ -1,9 +1,34 @@
|
|||||||
import { useToken } from './useToken';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useLogIn, useLogout } from 'queries';
|
||||||
|
import { LogInResponse } from 'request/user';
|
||||||
|
import { useGlobalState } from 'utils/hooks';
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
const token = useToken();
|
const queryClient = useQueryClient();
|
||||||
|
const { data: isAuthenticated } = useGlobalState(
|
||||||
|
['isAuthenticated'],
|
||||||
|
Boolean(localStorage.getItem('sbt-token')),
|
||||||
|
);
|
||||||
|
|
||||||
|
const logInMutation = useLogIn({
|
||||||
|
onSuccess(data: LogInResponse) {
|
||||||
|
localStorage.setItem('sbt-token', JSON.stringify(data.token));
|
||||||
|
localStorage.setItem('user-name', JSON.stringify(data.user_name));
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const logOutMutation = useLogout({
|
||||||
|
onSuccess() {
|
||||||
|
localStorage.removeItem('sbt-token');
|
||||||
|
queryClient.clear();
|
||||||
|
window.location.reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAuthenticated: Boolean(token),
|
isAuthenticated,
|
||||||
|
isAuthenticating: logInMutation.isLoading,
|
||||||
|
logIn: logInMutation.mutateAsync,
|
||||||
|
logOut: logOutMutation.mutateAsync,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,40 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
const DEFAULT_AUTO_REFRESH_TIME = 10_000;
|
|
||||||
|
|
||||||
export function useAutoRefreshFlag({
|
|
||||||
initialFlag = false,
|
|
||||||
autoStopTimeMs = DEFAULT_AUTO_REFRESH_TIME,
|
|
||||||
onStopByTimeout,
|
|
||||||
}: {
|
|
||||||
initialFlag?: boolean;
|
|
||||||
autoStopTimeMs?: number;
|
|
||||||
onStopByTimeout?(): void;
|
|
||||||
} = {}) {
|
|
||||||
const callbackRef = React.useRef(onStopByTimeout);
|
|
||||||
const timeoutRef = React.useRef(0);
|
|
||||||
const [shouldAutoRefresh, setShouldAutoRefresh] = React.useState(initialFlag);
|
|
||||||
|
|
||||||
return React.useMemo(
|
|
||||||
() => ({
|
|
||||||
timeOutId: timeoutRef.current,
|
|
||||||
isRefreshing: shouldAutoRefresh,
|
|
||||||
trigger: () => {
|
|
||||||
setShouldAutoRefresh(true);
|
|
||||||
timeoutRef.current = window.setTimeout(() => {
|
|
||||||
if (!shouldAutoRefresh) {
|
|
||||||
setShouldAutoRefresh(false);
|
|
||||||
if (callbackRef.current) {
|
|
||||||
callbackRef.current();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, autoStopTimeMs);
|
|
||||||
},
|
|
||||||
stop: () => {
|
|
||||||
setShouldAutoRefresh(false);
|
|
||||||
clearTimeout(timeoutRef.current);
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[shouldAutoRefresh, autoStopTimeMs],
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,31 +0,0 @@
|
|||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export const useGlobalState = <T>(key: any[], initialData: T) => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const stateQueries = useQuery<T>(
|
|
||||||
key,
|
|
||||||
() => Promise.reject('This thing never happens.'),
|
|
||||||
{
|
|
||||||
initialData,
|
|
||||||
staleTime: Infinity,
|
|
||||||
cacheTime: Infinity,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const setData = React.useCallback(
|
|
||||||
(param: React.SetStateAction<T>) => {
|
|
||||||
const oldState = queryClient.getQueryData<T>(key);
|
|
||||||
queryClient.setQueryData(
|
|
||||||
key,
|
|
||||||
typeof param === 'function' ? (param as Function)(oldState) : param,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[queryClient, key],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
setData,
|
|
||||||
data: stateQueries.data,
|
|
||||||
};
|
|
||||||
};
|
|
@ -1,15 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
|
|
||||||
export function useInitialCardHeight(
|
|
||||||
cardRef: React.MutableRefObject<HTMLDivElement>,
|
|
||||||
) {
|
|
||||||
const heightRef = React.useRef<number>(0);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (heightRef.current <= 0 && cardRef.current) {
|
|
||||||
heightRef.current = cardRef.current.clientHeight;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return heightRef.current;
|
|
||||||
}
|
|