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
|
||||
logs
|
||||
*.log
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# 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*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# rollup-plugin-visualizer
|
||||
stats.html
|
||||
|
||||
# Ignore all the installed packages
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# linguijs locales
|
||||
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/
|
||||
dist/
|
||||
keys/
|
||||
logs/
|
||||
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,
|
||||
"proseWrap": "preserve",
|
||||
"quoteProps": "as-needed",
|
||||
"requirePragma": false,
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "crlf",
|
||||
"jsxSingleQuote": false
|
||||
"useTabs": 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>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>OCR</title>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/favicon-96x96.png" />
|
||||
<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>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</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;
|
||||
#}
|
||||
}
|
9325
cope2n-fe/package-lock.json
generated
@ -1,57 +1,81 @@
|
||||
{
|
||||
"name": "vite-project",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"name": "sbt-ui",
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"start": "npm run extract && npm run compile && vite",
|
||||
"start": "npm run extract && npm run compile && vite --host",
|
||||
"build": "npm run extract && npm run compile && tsc && vite build",
|
||||
"serve": "vite preview",
|
||||
"extract": "lingui extract --clean",
|
||||
"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": {
|
||||
"@ant-design/colors": "^7.0.0",
|
||||
"@ant-design/colors": "^6.0.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",
|
||||
"@tanstack/react-query": "^4.20.4",
|
||||
"antd": "^5.1.2",
|
||||
"antd": "^5.4.0",
|
||||
"axios": "^1.2.2",
|
||||
"konva": "^8.3.14",
|
||||
"chart.js": "^4.4.1",
|
||||
"history": "^5.3.0",
|
||||
"lodash-es": "^4.17.21",
|
||||
"pdfjs-dist": "^3.4.120",
|
||||
"prop-types": "^15.8.1",
|
||||
"mousetrap": "^1.6.5",
|
||||
"react": "^18.2.0",
|
||||
"react-csv": "^2.2.2",
|
||||
"react-chartjs-2": "^5.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",
|
||||
"styled-components": "^5.3.6",
|
||||
"usehooks-ts": "^2.9.1",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"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/runtime": "^7.20.13",
|
||||
"@lingui/cli": "^3.17.0",
|
||||
"@lingui/core": "^3.17.0",
|
||||
"@lingui/macro": "^3.17.0",
|
||||
"@lingui/react": "^3.17.0",
|
||||
"@babel/runtime": "^7.13.10",
|
||||
"@lingui/cli": "^3.7.2",
|
||||
"@lingui/core": "^3.7.2",
|
||||
"@lingui/macro": "^3.7.2",
|
||||
"@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/react": "^18.0.26",
|
||||
"@types/react-dom": "^18.0.9",
|
||||
"@types/styled-components": "^5.1.26",
|
||||
"@types/uuid": "^9.0.0",
|
||||
"@types/node": "^18.11.18",
|
||||
"@types/react": "^18.0.20",
|
||||
"@types/react-dom": "^18.0.10",
|
||||
"@types/uuid": "^9.0.1",
|
||||
"@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",
|
||||
"prettier": "^2.8.3",
|
||||
"prettier-plugin-organize-imports": "^3.2.2",
|
||||
"typescript": "^4.9.3",
|
||||
"vite": "^4.0.0",
|
||||
"vite-tsconfig-paths": "^4.0.5"
|
||||
"prettier": "^2.8.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.1",
|
||||
"rollup-plugin-visualizer": "^5.9.0",
|
||||
"sass": "^1.57.1",
|
||||
"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",
|
||||
"name": "Create React App Sample",
|
||||
"short_name": "sbt",
|
||||
"name": "sbt",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
|
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;
|
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 { 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 { I18nProvider } from '@lingui/react';
|
||||
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 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 { useLocalStorage } from 'usehooks-ts';
|
||||
import { LOCALE_KEY } from '../../consts';
|
||||
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',
|
||||
},
|
||||
};
|
||||
}
|
||||
import { colors, shapes, typography } from 'theme/opus';
|
||||
import { useGlobalState, useLocalStorage } from 'utils/hooks';
|
||||
|
||||
const antdLocales: Record<string, Locale> = {
|
||||
en: enUS,
|
||||
vi: enhanceAntdViLocale(viVN),
|
||||
vi: viVN,
|
||||
};
|
||||
|
||||
interface InternationalizationProps {
|
||||
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 [localeStorage] = useLocalStorage(LOCALE_KEY, getLocale());
|
||||
const [localeStorage] = useLocalStorage('sbt-locale', getLocale());
|
||||
const { data: locale } = useGlobalState(['sbt-locale'], localeStorage);
|
||||
|
||||
React.useEffect(() => {
|
||||
dynamicActivate(localeStorage);
|
||||
}, [localeStorage]);
|
||||
dynamicActivate(locale);
|
||||
}, [locale]);
|
||||
|
||||
const antdLocale = React.useMemo(
|
||||
() => antdLocales[localeStorage],
|
||||
[localeStorage],
|
||||
);
|
||||
const antdLocale = React.useMemo(() => antdLocales[locale], [locale]);
|
||||
|
||||
return (
|
||||
<I18nProvider i18n={i18n} forceRenderOnLocaleChange={false}>
|
||||
<ConfigProvider locale={antdLocale}>{children}</ConfigProvider>
|
||||
<I18nProvider i18n={i18n} forceRenderOnLocaleChange>
|
||||
<ConfigProvider
|
||||
locale={antdLocale}
|
||||
theme={theme}
|
||||
prefixCls={ANT_PREFIX_CLASS}
|
||||
>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
</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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
75
cope2n-fe/src/components/left-menu/index.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { AppstoreOutlined, BarChartOutlined } 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);
|
75
cope2n-fe/src/components/left-menu/user-header.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
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 { logOut } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [, setLocaleStorage] = useLocalStorage('sbt-locale', DEFAULT_LOCALE);
|
||||
const { data: locale, setData: setLocale } = useGlobalState(
|
||||
['sbt-locale'],
|
||||
DEFAULT_LOCALE,
|
||||
);
|
||||
|
||||
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 ReportInformation } from './report-information';
|
||||
export { default as ReportMultiTypeChart } from './report-multitype-chart';
|
||||
export { default as ReportOverViewTable } from './report-overview-table';
|
||||
export * from './report-pie-chart';
|
@ -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;
|
472
cope2n-fe/src/components/report-detail/report-overview-table.tsx
Normal file
@ -0,0 +1,472 @@
|
||||
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: 'Subs',
|
||||
dataIndex: 'subSidiaries',
|
||||
key: 'subSidiaries',
|
||||
width: '100px',
|
||||
},
|
||||
{
|
||||
title: 'OCR extraction date',
|
||||
dataIndex: 'extractionDate',
|
||||
key: 'extractionDate',
|
||||
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',
|
||||
width: '130px',
|
||||
},
|
||||
{
|
||||
title: 'Image Quality',
|
||||
children: [
|
||||
{
|
||||
title: 'Successful',
|
||||
dataIndex: 'successfulNumber',
|
||||
key: 'successfulNumber',
|
||||
width: '50px',
|
||||
},
|
||||
{
|
||||
title: '% Successful',
|
||||
dataIndex: 'successfulPercentage',
|
||||
key: 'successfulPercentage',
|
||||
width: '120px',
|
||||
render: (_, record) => {
|
||||
return <span>{(record.successfulPercentage * 100).toFixed(2)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bad',
|
||||
dataIndex: 'badNumber',
|
||||
key: 'badNumber',
|
||||
width: '30px',
|
||||
},
|
||||
{
|
||||
title: '% Bad',
|
||||
dataIndex: 'badPercentage',
|
||||
key: 'badPercentage',
|
||||
width: '60px',
|
||||
render: (_, record) => {
|
||||
const isAbnormal = record.badPercentage * 100 > 10;
|
||||
return (
|
||||
<span style={{ color: isAbnormal ? 'red' : '' }}>
|
||||
{(record.badPercentage * 100).toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Average accuracy rate (%) \n(※ character-based)',
|
||||
children: [
|
||||
{
|
||||
title: 'IMEI / Serial no.',
|
||||
dataIndex: 'snImeiAAR',
|
||||
key: 'snImeiAAR',
|
||||
width: '130px',
|
||||
render: (_, record) => {
|
||||
const isAbnormal = record.snImeiAAR < 98;
|
||||
return (
|
||||
<span style={{ color: isAbnormal ? 'red' : '' }}>
|
||||
{record.snImeiAAR.toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Purchase date',
|
||||
dataIndex: 'purchaseDateAAR',
|
||||
key: 'purchaseDateAAR',
|
||||
width: '130px',
|
||||
render: (_, record) => {
|
||||
const isAbnormal = record.purchaseDateAAR < 98;
|
||||
return (
|
||||
<span style={{ color: isAbnormal ? 'red' : '' }}>
|
||||
{record.purchaseDateAAR.toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Retailer name',
|
||||
dataIndex: 'retailerNameAAR',
|
||||
key: 'retailerNameAAR',
|
||||
width: '130px',
|
||||
render: (_, record) => {
|
||||
const isAbnormal = record.retailerNameAAR < 98;
|
||||
return (
|
||||
<span style={{ color: isAbnormal ? 'red' : '' }}>
|
||||
{record.retailerNameAAR.toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Average Processing Time',
|
||||
children: [
|
||||
{
|
||||
title: 'SN/IMEI',
|
||||
dataIndex: 'snImeiAPT',
|
||||
key: 'snImeiAPT',
|
||||
render: (_, record) => {
|
||||
const isAbnormal = record.snImeiAPT > 2;
|
||||
return (
|
||||
<span style={{ color: isAbnormal ? 'red' : '' }}>
|
||||
{record.snImeiAPT.toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Invoice',
|
||||
dataIndex: 'invoiceAPT',
|
||||
key: 'invoiceAPT',
|
||||
render: (_, record) => {
|
||||
const isAbnormal = record.invoiceAPT > 2;
|
||||
return (
|
||||
<span style={{ color: isAbnormal ? 'red' : '' }}>
|
||||
{record.invoiceAPT.toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Usage (Transaction Count)',
|
||||
children: [
|
||||
{
|
||||
title: 'SN/IMEI',
|
||||
dataIndex: 'snImeiTC',
|
||||
key: 'snImeiTC',
|
||||
},
|
||||
{
|
||||
title: 'Invoice',
|
||||
dataIndex: 'invoiceTC',
|
||||
key: 'invoiceTC',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface ReportOverViewTableProps {
|
||||
pagination: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
setPagination: React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
page: number;
|
||||
page_size: number;
|
||||
}>
|
||||
>;
|
||||
isLoading: boolean;
|
||||
data: any;
|
||||
}
|
||||
|
||||
const ReportOverViewTable: React.FC<ReportOverViewTableProps> = ({
|
||||
pagination,
|
||||
setPagination,
|
||||
isLoading,
|
||||
data,
|
||||
}) => {
|
||||
// const [pagination, setPagination] = useState({
|
||||
// page: 1,
|
||||
// page_size: 10,
|
||||
// });
|
||||
// const { isLoading, data } = useOverViewReport({
|
||||
// page: pagination.page,
|
||||
// });
|
||||
|
||||
console.log('check >>>', pagination, isLoading, data);
|
||||
|
||||
const overviewDataResponse = data as any;
|
||||
const dataSubsRows = overviewDataResponse?.overview_data
|
||||
.map((item, index) => {
|
||||
if (item.subs.includes('+')) {
|
||||
return {
|
||||
key: index,
|
||||
subSidiaries: '',
|
||||
extractionDate: item.extraction_date,
|
||||
snOrImeiNumber: '',
|
||||
invoiceNumber: '',
|
||||
totalImages: item.total_images,
|
||||
successfulNumber: item.images_quality.successful,
|
||||
successfulPercentage: item.images_quality.successful_percent,
|
||||
badNumber: item.images_quality.bad,
|
||||
badPercentage: item.images_quality.bad_percent,
|
||||
snImeiAAR: item.average_accuracy_rate.imei,
|
||||
purchaseDateAAR: item.average_accuracy_rate.purchase_date,
|
||||
retailerNameAAR: item.average_accuracy_rate.retailer_name,
|
||||
snImeiAPT: item.average_processing_time.imei,
|
||||
invoiceAPT: item.average_processing_time.invoice,
|
||||
snImeiTC: item.usage.imei,
|
||||
invoiceTC: item.usage.invoice,
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((item) => item);
|
||||
|
||||
const expandedRowRender = () => {
|
||||
const subData = overviewDataResponse?.overview_data
|
||||
.map((item, index) => {
|
||||
if (!item.subs.includes('+')) {
|
||||
return {
|
||||
key: index,
|
||||
subSidiaries: item.subs,
|
||||
extractionDate: item.extraction_date,
|
||||
snOrImeiNumber: item.num_imei,
|
||||
invoiceNumber: item.num_invoice,
|
||||
totalImages: item.total_images,
|
||||
successfulNumber: item.images_quality.successful,
|
||||
successfulPercentage: item.images_quality.successful_percent,
|
||||
badNumber: item.images_quality.bad,
|
||||
badPercentage: item.images_quality.bad_percent,
|
||||
snImeiAAR: item.average_accuracy_rate.imei,
|
||||
purchaseDateAAR: item.average_accuracy_rate.purchase_date,
|
||||
retailerNameAAR: item.average_accuracy_rate.retailer_name,
|
||||
snImeiAPT: item.average_processing_time.imei,
|
||||
invoiceAPT: item.average_processing_time.invoice,
|
||||
snImeiTC: item.usage.imei,
|
||||
invoiceTC: item.usage.invoice,
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((item) => item);
|
||||
|
||||
const subColumns: TableColumnsType<DataType> = [
|
||||
{
|
||||
title: 'Subs',
|
||||
dataIndex: 'subSidiaries',
|
||||
key: 'subSidiaries',
|
||||
width: '100px',
|
||||
},
|
||||
{
|
||||
title: 'OCR extraction date',
|
||||
dataIndex: 'extractionDate',
|
||||
key: 'extractionDate',
|
||||
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',
|
||||
width: '130px',
|
||||
},
|
||||
{
|
||||
title: 'Successful',
|
||||
dataIndex: 'successfulNumber',
|
||||
key: 'successfulNumber',
|
||||
width: '50px',
|
||||
},
|
||||
{
|
||||
title: '% Successful',
|
||||
dataIndex: 'successfulPercentage',
|
||||
key: 'successfulPercentage',
|
||||
width: '120px',
|
||||
render: (_, record) => {
|
||||
return <span>{(record.successfulPercentage * 100).toFixed(2)}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Bad',
|
||||
dataIndex: 'badNumber',
|
||||
key: 'badNumber',
|
||||
width: '30px',
|
||||
},
|
||||
{
|
||||
title: '% Bad',
|
||||
dataIndex: 'badPercentage',
|
||||
key: 'badPercentage',
|
||||
width: '60px',
|
||||
render: (_, record) => {
|
||||
const isAbnormal = record.badPercentage * 100 > 10;
|
||||
return (
|
||||
<span style={{ color: isAbnormal ? 'red' : '' }}>
|
||||
{(record.badPercentage * 100).toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
title: 'IMEI / Serial no.',
|
||||
dataIndex: 'snImeiAAR',
|
||||
key: 'snImeiAAR',
|
||||
width: '130px',
|
||||
render: (_, record) => {
|
||||
const isAbnormal = record.snImeiAAR < 98;
|
||||
return (
|
||||
<span style={{ color: isAbnormal ? 'red' : '' }}>
|
||||
{record.snImeiAAR.toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Purchase date',
|
||||
dataIndex: 'purchaseDateAAR',
|
||||
key: 'purchaseDateAAR',
|
||||
width: '130px',
|
||||
render: (_, record) => {
|
||||
const isAbnormal = record.purchaseDateAAR < 98;
|
||||
return (
|
||||
<span style={{ color: isAbnormal ? 'red' : '' }}>
|
||||
{record.purchaseDateAAR.toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Retailer name',
|
||||
dataIndex: 'retailerNameAAR',
|
||||
key: 'retailerNameAAR',
|
||||
width: '130px',
|
||||
render: (_, record) => {
|
||||
const isAbnormal = record.retailerNameAAR < 98;
|
||||
return (
|
||||
<span style={{ color: isAbnormal ? 'red' : '' }}>
|
||||
{record.retailerNameAAR.toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
title: 'SN/IMEI',
|
||||
dataIndex: 'snImeiAPT',
|
||||
key: 'snImeiAPT',
|
||||
render: (_, record) => {
|
||||
const isAbnormal = record.snImeiAPT > 2;
|
||||
return (
|
||||
<span style={{ color: isAbnormal ? 'red' : '' }}>
|
||||
{record.snImeiAPT.toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Invoice',
|
||||
dataIndex: 'invoiceAPT',
|
||||
key: 'invoiceAPT',
|
||||
render: (_, record) => {
|
||||
const isAbnormal = record.invoiceAPT > 2;
|
||||
return (
|
||||
<span style={{ color: isAbnormal ? 'red' : '' }}>
|
||||
{record.invoiceAPT.toFixed(2)}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'SN/IMEI',
|
||||
dataIndex: 'snImeiTC',
|
||||
key: 'snImeiTC',
|
||||
},
|
||||
{
|
||||
title: 'Invoice',
|
||||
dataIndex: 'invoiceTC',
|
||||
key: 'invoiceTC',
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Table
|
||||
columns={subColumns}
|
||||
dataSource={subData}
|
||||
pagination={false}
|
||||
bordered
|
||||
// showHeader={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<Table
|
||||
loading={isLoading}
|
||||
columns={columns}
|
||||
dataSource={dataSubsRows}
|
||||
bordered
|
||||
size='small'
|
||||
expandable={{ expandedRowRender, defaultExpandedRowKeys: [0, 1] }}
|
||||
scroll={{ x: 2000 }}
|
||||
pagination={{
|
||||
current: pagination.page,
|
||||
pageSize: pagination.page_size,
|
||||
total: overviewDataResponse?.page.count,
|
||||
showTotal: (total, range) =>
|
||||
`${range[0]}-${range[1]} of ${total} items`,
|
||||
onChange: (page, pageSize) => {
|
||||
setPagination({
|
||||
page,
|
||||
page_size: pageSize || 10,
|
||||
});
|
||||
},
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ReportOverViewTable;
|
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;
|
169
cope2n-fe/src/components/report-detail/report-table.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
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';
|
||||
import { downloadReport } from 'request/report';
|
||||
|
||||
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: report_data?.page.total_pages || 1,
|
||||
page_size: 10,
|
||||
}));
|
||||
|
||||
const handleDownloadReport = async (report_id: string) => {
|
||||
const reportFile = await downloadReport(report_id);
|
||||
const anchorElement = document.createElement('a');
|
||||
anchorElement.href = URL.createObjectURL(reportFile);
|
||||
anchorElement.download = `${report_id}.xlsx`; // Set the desired new filename
|
||||
|
||||
document.body.appendChild(anchorElement);
|
||||
anchorElement.click();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(anchorElement);
|
||||
URL.revokeObjectURL(anchorElement.href);
|
||||
};
|
||||
|
||||
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',
|
||||
render: (_, record) => {
|
||||
return <>{record.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 onClick={() => handleDownloadReport(record.report_id)}>
|
||||
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_SIZE = 10;
|
||||
export const TOKEN_KEY = 'cope2n-token';
|
||||
export const LOCALE_KEY = 'cope2n-locale';
|
||||
export const DEFAULT_REFRESH_INTERVAL = 2_000;
|
||||
export const PRECISION = 3;
|
||||
export const DATE_FORMAT = 'YYYY-MM-DD';
|
||||
export const TIME_FORMAT = 'HH:mm';
|
||||
export const DATE_TIME_FORMAT = `${DATE_FORMAT} ${TIME_FORMAT}`;
|
||||
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() {
|
||||
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 {
|
||||
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;
|
||||
}
|
20
cope2n-fe/src/hooks/useNavigateRef.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { NavigateFunction, useNavigate } from 'react-router-dom';
|
||||
|
||||
/*
|
||||
Q: Why do I create this hook?
|
||||
A: `navigation` function is retrieved from react-router-dom context. This function is created new everytime context changes.
|
||||
So, there will be sometimes you get stuck in old `navigate` function and feature won't work (this can happen by JS closure mechanism or react memorization).
|
||||
To make sure that you always use the latest `navigate` function, use this hook.
|
||||
Note: Only prioritize to use this hook when something does not work as expected. E.g. https://devops.sdsdev.co.kr/jira/browse/IRVAIOPER-461
|
||||
*/
|
||||
export function useNavigateRef() {
|
||||
const navigate = useNavigate();
|
||||
const navigateRef = React.useRef<NavigateFunction>(navigate);
|
||||
|
||||
React.useEffect(() => {
|
||||
navigateRef.current = navigate;
|
||||
}, [navigate]);
|
||||
|
||||
return navigateRef;
|
||||
}
|
@ -1,20 +0,0 @@
|
||||
import * as React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useLocalStorage } from 'usehooks-ts';
|
||||
import { TOKEN_KEY } from '../consts';
|
||||
|
||||
export function useToken() {
|
||||
const { search } = useLocation();
|
||||
const query = new URLSearchParams(search);
|
||||
const paramToken = query.get(TOKEN_KEY);
|
||||
|
||||
const [localToken, setLocalToken] = useLocalStorage(TOKEN_KEY, '');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (paramToken) {
|
||||
setLocalToken(paramToken);
|
||||
}
|
||||
}, [paramToken]);
|
||||
|
||||
return localToken;
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import { i18n } from '@lingui/core';
|
||||
import { en, vi } from 'make-plural/plurals';
|
||||
import { LOCALE_KEY } from './consts';
|
||||
|
||||
export const DEFAULT_LOCALE = 'en';
|
||||
|
||||
@ -19,7 +18,7 @@ export async function dynamicActivate(locale: string) {
|
||||
|
||||
export function getLocale() {
|
||||
try {
|
||||
const localeStorage = localStorage.getItem(LOCALE_KEY);
|
||||
const localeStorage = localStorage.getItem('sbt-locale');
|
||||
return JSON.parse(localeStorage || '') as string;
|
||||
} catch (error) {
|
||||
return DEFAULT_LOCALE;
|
||||
|
@ -1,11 +1,30 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import Internationalization from 'components/internaltionalization';
|
||||
import { GlobalSpin } from 'components/spin';
|
||||
import { queryClient } from 'queries';
|
||||
import { Suspense } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import AppRoutes from 'routes';
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
import 'antd/dist/reset.css';
|
||||
import './theme/compose.scss';
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
function App() {
|
||||
return (
|
||||
<Suspense fallback={<GlobalSpin />}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Internationalization>
|
||||
{/* <AbilityContext.Provider value={appAbilitiy}> */}
|
||||
<AppRoutes />
|
||||
{/* </AbilityContext.Provider> */}
|
||||
</Internationalization>
|
||||
<ReactQueryDevtools initialIsOpen={false} />
|
||||
</QueryClientProvider>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
const container = document.getElementById('root')!;
|
||||
const root = createRoot(container);
|
||||
root.render(<App />);
|
||||
|