feat(project): base smatec's frontend

This commit is contained in:
Tran Anh Tuan
2026-01-21 11:48:57 +07:00
commit 5c2a909bed
138 changed files with 43666 additions and 0 deletions

3
.eslintrc.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
extends: require.resolve('@umijs/max/eslint'),
};

16
.gitignore vendored Normal file
View File

@@ -0,0 +1,16 @@
/node_modules
/.env.local
/.umirc.local.ts
/config/config.local.ts
/src/.umi
/src/.umi-production
/src/.umi-test
/.umi
/.umi-production
/.umi-test
/dist
/.mfsu
.swc
.turbopack
/dist
.DS_Store

1
.husky/commit-msg Normal file
View File

@@ -0,0 +1 @@
npx --no-install max verify-commit $1

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx --no-install lint-staged --quiet

17
.lintstagedrc Normal file
View File

@@ -0,0 +1,17 @@
{
"*.{md,json}": [
"prettier --cache --write"
],
"*.{js,jsx}": [
"max lint --fix --eslint-only",
"prettier --cache --write"
],
"*.{css,less}": [
"max lint --fix --stylelint-only",
"prettier --cache --write"
],
"*.ts?(x)": [
"max lint --fix --eslint-only",
"prettier --cache --parser=typescript --write"
]
}

2
.npmrc Normal file
View File

@@ -0,0 +1,2 @@
registry=https://registry.npmjs.com/

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
.umi
.umi-production

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"printWidth": 80,
"singleQuote": true,
"trailingComma": "all",
"proseWrap": "never",
"overrides": [{ "files": ".prettierrc", "options": { "parser": "json" } }],
"plugins": ["prettier-plugin-organize-imports", "prettier-plugin-packagejson"]
}

11
.stylelintrc.js Normal file
View File

@@ -0,0 +1,11 @@
module.exports = {
extends: require.resolve('@umijs/max/stylelint'),
rules: {
'at-rule-no-unknown': [
true,
{
ignoreAtRules: ['tailwind'],
},
],
},
};

36
.umirc.gms.ts Normal file
View File

@@ -0,0 +1,36 @@
import { defineConfig } from '@umijs/max';
import {
alarmsRoute,
commonManagerRoutes,
loginRoute,
managerRouteBase,
notFoundRoute,
profileRoute,
} from './config/routes';
export default defineConfig({
favicons: ['/gms-logo.png'],
layout: {
title: 'GMS',
},
routes: [
loginRoute,
alarmsRoute,
profileRoute,
{
name: 'gms.monitor',
icon: 'icon-monitor',
path: '/monitor',
component: './Slave/GMS/Monitor',
},
{
...managerRouteBase,
routes: [...commonManagerRoutes],
},
{
path: '/',
redirect: '/monitor',
},
notFoundRoute,
],
});

66
.umirc.sgw.ts Normal file
View File

@@ -0,0 +1,66 @@
import { defineConfig } from '@umijs/max';
import {
alarmsRoute,
commonManagerRoutes,
loginRoute,
managerRouteBase,
notFoundRoute,
profileRoute,
} from './config/routes';
export default defineConfig({
favicons: ['/sgw-logo.png'],
layout: {
title: 'SGW',
},
routes: [
loginRoute,
alarmsRoute,
profileRoute,
{
name: 'sgw.map',
icon: 'icon-map',
path: '/map',
component: './Slave/SGW/Map',
},
{
name: 'sgw.trips',
icon: 'icon-trip',
path: '/trips',
component: './Slave/SGW/Trip',
},
{
...managerRouteBase,
routes: [
...commonManagerRoutes,
{
name: 'sgw.fishes',
icon: 'icon-fish',
path: '/manager/fish',
routes: [
{
path: '/manager/fish',
component: './Slave/SGW/Manager/Fish',
},
],
},
{
name: 'sgw.zones',
icon: 'icon-prohibited',
path: '/manager/area',
routes: [
{
path: '/manager/area',
component: './Slave/SGW/Manager/Area',
},
],
},
],
},
{
path: '/',
redirect: '/map',
},
notFoundRoute,
],
});

109
.umirc.spole.ts Normal file
View File

@@ -0,0 +1,109 @@
import { defineConfig } from '@umijs/max';
import {
alarmsRoute,
commonManagerRoutes,
loginRoute,
managerRouteBase,
notFoundRoute,
profileRoute,
} from './config/routes';
export default defineConfig({
favicons: ['/spole-logo.png'],
layout: {
title: 'Spole',
},
routes: [
loginRoute,
alarmsRoute,
profileRoute,
{
name: 'spole.monitoring',
icon: 'icon-monitoring',
path: '/home',
component: './Slave/Spole/Monitor',
},
{
name: 'spole.mira',
icon: 'icon-speaker',
path: '/mira',
component: './Slave/Spole/Mira',
routes: [
// {
// path: '/manager/mira',
// component: './Slave/Spole/Manager/Mira',
// },
],
},
{
name: 'spole.miva',
icon: 'icon-led',
path: '/miva',
component: './Slave/Spole/Miva',
routes: [
// {
// path: '/manager/miva',
// component: './Slave/Spole/Manager/Miva',
// },
],
},
{
name: 'spole.traffic-light',
icon: 'icon-traffic-light',
path: '/traffic-light',
component: './Slave/Spole/TrafficLight',
routes: [
// {
// path: '/manager/miva',
// component: './Slave/Spole/Manager/Miva',
// },
],
},
{
name: 'spole.street-light',
icon: 'icon-street-light',
path: '/street-light',
component: './Slave/Spole/StreetLight',
routes: [
// {
// path: '/manager/miva',
// component: './Slave/Spole/Manager/Miva',
// },
],
},
{
name: 'spole.media',
icon: 'icon-media-upload',
path: '/media',
component: './Slave/Spole/Media',
routes: [
// {
// path: '/manager/miva',
// component: './Slave/Spole/Manager/Miva',
// },
],
},
{
...managerRouteBase,
routes: [
...commonManagerRoutes,
// {
// name: 'light',
// icon: 'icon-light',
// path: '/manager/light',
// routes: [
// {
// path: '/manager/light',
// component: './Slave/Spole/Manager/Light',
// },
// ],
// },
],
},
{
path: '/',
redirect: '/home',
},
notFoundRoute,
],
});

58
.umirc.ts Normal file
View File

@@ -0,0 +1,58 @@
import { defineConfig } from '@umijs/max';
import proxyDev from './config/proxy_dev';
import proxyProd from './config/proxy_prod';
import {
alarmsRoute,
commonManagerRoutes,
loginRoute,
managerRouteBase,
notFoundRoute,
} from './config/routes';
const envConfig = process.env as { REACT_APP_ENV?: 'dev' | 'test' | 'prod' };
const rawEnv = envConfig.REACT_APP_ENV;
const isProdBuild = process.env.NODE_ENV === 'production';
const resolvedEnv = isProdBuild && !rawEnv ? 'prod' : rawEnv || 'dev';
const proxyConfig = isProdBuild ? proxyProd : proxyDev;
const routes = [
loginRoute,
alarmsRoute,
{
...managerRouteBase,
routes: commonManagerRoutes,
},
notFoundRoute,
];
export default defineConfig({
antd: {},
access: {},
model: {},
initialState: {},
request: {},
tailwindcss: {},
layout: {
title: 'BaseProject',
},
extraBabelPlugins:
process.env.NODE_ENV === 'production'
? [['transform-remove-console', { exclude: ['error'] }]]
: [],
mfsu: false,
locale: {
useLocalStorage: true,
default: 'vi-VN',
baseNavigator: false,
antd: true,
title: false,
baseSeparator: '-',
},
// Only use base routes when not in a specific environment that provides its own routes
routes: process.env.DOMAIN_ENV ? undefined : routes,
npmClient: 'pnpm',
// Use the dev configuration from proxy_dev
proxy: proxyConfig[resolvedEnv],
define: {
'process.env.DOMAIN_ENV': process.env.DOMAIN_ENV,
},
});

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"typescript.tsdk": "node_modules/typescript/lib",
"typescript.enablePromptUseWorkspaceTsdk": true
// "typescript.preferences.autoImportFileExcludePatterns": [
// "node_modules"
// ]
}

44
config/proxy_dev.ts Normal file
View File

@@ -0,0 +1,44 @@
const getTarget = () => {
const env = process.env.DOMAIN_ENV;
switch (env) {
case 'gms':
return 'https://gms.smatec.com.vn';
case 'spole':
return 'https://spole.gms.vn';
case 'sgw':
return 'https://sgw.gms.vn';
default:
return 'https://sgw.gms.vn';
}
};
const target = getTarget();
const proxyDev: Record<string, any> = {
dev: {
'/api': {
target: target,
changeOrigin: true,
},
'/geoserver': {
target: target,
changeOrigin: true,
},
},
test: {
'/test': {
target: 'https://proapi.azurewebsites.net',
changeOrigin: true,
secure: false,
},
},
prod: {
'/test': {
target: 'https://prod-sgw-device.gms.vn',
changeOrigin: true,
secure: false,
},
},
};
export default proxyDev;

86
config/proxy_prod.ts Normal file
View File

@@ -0,0 +1,86 @@
// Lấy hostname từ URL hiện tại của trang web
const getCurrentHostname = () => {
// Trong build-time, sử dụng environment variable
if (typeof window === 'undefined') {
return process.env.REACT_APP_HOSTNAME || 'localhost';
}
// Trong runtime, lấy từ URL của trang web
return window.location.hostname;
};
// Lấy port cho GeoServer từ environment variable hoặc default là 8080
const getGeoserverPort = () => {
return process.env.REACT_APP_GEOSERVER_PORT || '8080';
};
// Tạo proxy config động dựa trên hostname hiện tại
const createDynamicProxy = () => {
const hostname = getCurrentHostname();
const geoserverPort = getGeoserverPort();
const isLocalhost =
hostname === 'localhost' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
hostname.startsWith('127.');
// Với production, ta sẽ proxy:
// - /api đến cùng hostname với port 81
// - /geoserver đến cùng hostname với port GEOSERVER_PORT (default: 8080)
// Ví dụ: nếu truy cập https://example.com thì API sẽ đến http://example.com:81/api
// và GeoServer sẽ đến http://example.com:[GEOSERVER_PORT]/geoserver
return {
dev: {
'/api': {
target: `http://${hostname}:81`,
changeOrigin: true,
pathRewrite: { '^/api': '/api' },
},
'/geoserver': {
target: `http://${hostname}:${geoserverPort}`,
changeOrigin: true,
pathRewrite: { '^/geoserver': '/geoserver' },
},
},
test: {
'/api': {
target: isLocalhost
? `http://${hostname}:81`
: `https://${hostname}:81`,
changeOrigin: true,
secure: !isLocalhost,
pathRewrite: { '^/api': '/api' },
},
'/geoserver': {
target: isLocalhost
? `http://${hostname}:${geoserverPort}`
: `https://${hostname}:${geoserverPort}`,
changeOrigin: true,
secure: !isLocalhost,
pathRewrite: { '^/geoserver': '/geoserver' },
},
},
prod: {
'/api': {
target: isLocalhost
? `http://${hostname}:81`
: `https://${hostname}:81`,
changeOrigin: true,
secure: !isLocalhost,
pathRewrite: { '^/api': '/api' },
},
'/geoserver': {
target: isLocalhost
? `http://${hostname}:${geoserverPort}`
: `https://${hostname}:${geoserverPort}`,
changeOrigin: true,
secure: !isLocalhost,
pathRewrite: { '^/geoserver': '/geoserver' },
},
},
};
};
const proxyProd: Record<string, any> = createDynamicProxy();
export default proxyProd;

131
config/request_dev.ts Normal file
View File

@@ -0,0 +1,131 @@
import { HTTPSTATUS } from '@/constants';
import { ROUTE_LOGIN } from '@/constants/routes';
import { getToken, removeToken } from '@/utils/storage';
import { history, RequestConfig } from '@umijs/max';
import { message } from 'antd';
// Error handling scheme: Error types
// enum ErrorShowType {
// SILENT = 0,
// WARN_MESSAGE = 1,
// ERROR_MESSAGE = 2,
// NOTIFICATION = 3,
// REDIRECT = 9,
// }
// Response data structure agreed with the backend
// interface ResponseStructure<T = any> {
// success: boolean;
// data: T;
// errorCode?: number;
// errorMessage?: string;
// showType?: ErrorShowType;
// }
// const codeMessage = {
// 200: 'The server successfully returned the requested data。',
// 201: 'New or modified data succeeded。',
// 202: 'A request has been queued in the background (asynchronous task)。',
// 204: 'Data deleted successfully。',
// 400: 'There is an error in the request sent, the server did not perform the operation of creating or modifying data。',
// 401: 'The user does not have permission (token, username, password is wrong) 。',
// 403: 'User is authorized, but access is prohibited。',
// 404: 'The request issued was for a non-existent record, the server did not operate。',
// 406: 'The requested format is not available。',
// 410: 'The requested resource is permanently deleted and will no longer be available。',
// 422: 'When creating an object, a validation error occurred。',
// 500: 'Server error, please check the server。',
// 502: 'Gateway error。',
// 503: 'Service unavailable, server temporarily overloaded or maintained。',
// 504: 'Gateway timeout。',
// };
// Runtime configuration
export const handleRequestConfig: RequestConfig = {
// Unified request settings
timeout: 20000,
validateStatus: (status) => {
return (
(status >= 200 && status < 300) || status === HTTPSTATUS.HTTP_NOTFOUND
);
},
headers: { 'X-Requested-With': 'XMLHttpRequest' },
// Error handling: umi@3's error handling scheme.
errorConfig: {
// Error throwing
errorThrower: (res: any) => {
// console.log('Response from backend:', res);
const { success, data, errorCode, errorMessage, showType } = res;
if (!success) {
const error: any = new Error(errorMessage);
error.name = 'BizError';
error.info = { errorCode, errorMessage, showType, data };
throw error; // Throw custom error
}
},
// Error catching and handling
errorHandler: (error: any) => {
if (error.response) {
const { status } = error.response;
// Ưu tiên: codeMessage → backend message → statusText
// const errMsg =
// codeMessage[status as keyof typeof codeMessage] ||
// data?.message ||
// statusText ||
// 'Unknown error';
if (status === HTTPSTATUS.HTTP_UNAUTHORIZED) {
removeToken();
history.push(ROUTE_LOGIN);
} else if (status === HTTPSTATUS.HTTP_SERVERERROR) {
// message.error('💥 Internal server error!');
} else {
message.error(`${status}: ${error.message || 'Error'}`);
}
} else if (error.request) {
message.error('🚨 No response from server!');
} else {
message.error(`⚠️ Request setup error: ${error.message}`);
}
},
},
// Request interceptors
requestInterceptors: [
(url: string, options: any) => {
const token = getToken();
// console.log('Token: ', token);
return {
url,
options: {
...options,
headers: {
...options.headers,
...(token ? { Authorization: `${token}` } : {}),
},
},
};
},
],
// Unwrap data from backend response
responseInterceptors: [
(response) => {
// console.log('Response from server: ', response);
// const res = response.data as ResponseStructure<any>;
// if (res && res.success) {
// // ✅ Trả ra data luôn thay vì cả object
// return res.data;
// }
return {
status: response.status,
statusText: response.statusText,
headers: response.headers,
config: response.config,
data: response.data,
};
},
],
};

107
config/request_prod.ts Normal file
View File

@@ -0,0 +1,107 @@
import { HTTPSTATUS } from '@/constants';
import { ROUTE_LOGIN } from '@/constants/routes';
import { getToken, removeToken } from '@/utils/storage';
import { history, RequestConfig } from '@umijs/max';
import { message } from 'antd';
const codeMessage = {
200: 'The server successfully returned the requested data。',
201: 'New or modified data succeeded。',
202: 'A request has been queued in the background (asynchronous task)。',
204: 'Data deleted successfully。',
400: 'There is an error in the request sent, the server did not perform the operation of creating or modifying data。',
401: 'The user does not have permission (token, username, password is wrong) 。',
403: 'User is authorized, but access is prohibited。',
404: 'The request issued was for a non-existent record, the server did not operate。',
406: 'The requested format is not available。',
410: 'The requested resource is permanently deleted and will no longer be available。',
422: 'When creating an object, a validation error occurred。',
500: 'Server error, please check the server。',
502: 'Gateway error。',
503: 'Service unavailable, server temporarily overloaded or maintained。',
504: 'Gateway timeout。',
};
// Runtime configuration
export const handleRequestConfig: RequestConfig = {
// Unified request settings
timeout: 20000,
validateStatus: (status) => {
return (
(status >= 200 && status < 300) || status === HTTPSTATUS.HTTP_NOTFOUND
);
},
headers: { 'X-Requested-With': 'XMLHttpRequest' },
// Error handling: umi@3's error handling scheme.
errorConfig: {
// Error throwing
errorThrower: (res: any) => {
// console.log('Response from backend:', res);
const { success, data, errorCode, errorMessage, showType } = res;
if (!success) {
const error: any = new Error(errorMessage);
error.name = 'BizError';
error.info = { errorCode, errorMessage, showType, data };
throw error; // Throw custom error
}
},
// Error catching and handling
errorHandler: (error: any) => {
if (error.response) {
const { status, statusText, data } = error.response;
// Ưu tiên: codeMessage → backend message → statusText
const errMsg =
codeMessage[status as keyof typeof codeMessage] ||
data?.message ||
statusText ||
'Unknown error';
message.error(`${status}: ${errMsg}`);
if (status === 401) {
removeToken();
history.push(ROUTE_LOGIN);
}
} else if (error.request) {
message.error('🚨 No response from server!');
} else if (status) {
// message.error('💥 Internal server error!');
} else {
message.error(`⚠️ Request setup error: ${error.message}`);
}
},
},
// Request interceptors
requestInterceptors: [
(url: string, options: any) => {
// console.log('URL Request:', url, options);
// Chỉ cần thêm token, proxy sẽ xử lý việc redirect đến đúng port
// URL sẽ bắt đầu với /api và proxy sẽ chuyển đến hostname:81/api
const token = getToken();
return {
url: url,
options: {
...options,
headers: {
...options.headers,
...(token ? { Authorization: `${token}` } : {}),
},
},
};
},
],
// Unwrap data from backend response
// responseInterceptors: [
// (response) => {
// const res = response.data as ResponseStructure<any>;
// if (res && res.success) {
// // ✅ Trả ra data luôn thay vì cả object
// return res.data;
// }
// return response.data;
// },
// ],
};

89
config/routes.ts Normal file
View File

@@ -0,0 +1,89 @@
export const loginRoute = {
title: 'Login',
path: '/login',
component: './Auth',
layout: false,
};
export const profileRoute = {
name: 'profile',
icon: 'icon-user',
path: '/profile',
routes: [
{
path: '/profile',
component: './Profile',
},
],
flatMenu: true,
};
export const alarmsRoute = {
name: 'alarms',
icon: 'icon-alarm',
path: '/alarms',
routes: [
{
path: '/alarms',
component: './Alarm',
},
],
};
export const commonManagerRoutes = [
{
name: 'devices',
icon: 'icon-gateway',
path: '/manager/devices',
routes: [
{
path: '/manager/devices',
component: './Manager/Device',
},
],
},
{
name: 'groups',
icon: 'icon-tree',
path: '/manager/groups',
routes: [
{
path: '/manager/groups',
component: './Manager/Group',
},
],
},
{
name: 'users',
icon: 'icon-users',
path: '/manager/users',
routes: [
{
path: '/manager/users',
component: './Manager/User',
},
],
},
{
name: 'logs',
icon: 'icon-diary',
path: '/manager/logs',
routes: [
{
path: '/manager/logs',
component: './Manager/Log',
},
],
},
];
export const managerRouteBase = {
name: 'manager',
icon: 'icon-setting',
path: '/manager',
access: 'canAdmin_SysAdmin',
};
export const notFoundRoute = {
path: '*',
component: './Exception/NotFound',
};

0
mock/userAPI.ts Normal file
View File

22459
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

46
package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"private": true,
"author": "",
"scripts": {
"build": "max build",
"build:gms": "UMI_ENV=gms DOMAIN_ENV=gms max build",
"build:sgw": "UMI_ENV=sgw DOMAIN_ENV=sgw max build",
"build:spole": "UMI_ENV=spole DOMAIN_ENV=spole max build",
"dev": "max dev",
"format": "prettier --cache --write .",
"postinstall": "max setup",
"prepare": "husky",
"setup": "max setup",
"start": "npm run dev",
"start:gms": "UMI_ENV=gms DOMAIN_ENV=gms max dev",
"start:sgw": "UMI_ENV=sgw DOMAIN_ENV=sgw max dev",
"start:spole": "UMI_ENV=spole DOMAIN_ENV=spole max dev"
},
"dependencies": {
"@ant-design/icons": "^5.0.1",
"@ant-design/pro-components": "^2.8.10",
"@umijs/max": "^4.6.23",
"antd": "^5.4.0",
"classnames": "^2.5.1",
"dayjs": "^1.11.19",
"moment": "^2.30.1",
"ol": "^10.6.1",
"reconnecting-websocket": "^4.4.0"
},
"devDependencies": {
"@types/react": "^18.0.33",
"@types/react-dom": "^18.0.11",
"babel-plugin-transform-remove-console": "^6.9.4",
"baseline-browser-mapping": "^2.9.6",
"husky": "^9",
"lint-staged": "^13.2.0",
"prettier": "^2.8.7",
"prettier-plugin-organize-imports": "^3.2.2",
"prettier-plugin-packagejson": "^2.4.3",
"tailwindcss": "^3",
"typescript": "^5.0.3"
},
"gms-version": "1.0.0",
"sgw-version": "1.0.0",
"spole-version": "1.0.0"
}

14482
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

1
public/avatar.svg Normal file
View File

@@ -0,0 +1 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1679201365371" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="1897" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M509.31257004 540.79389452h4.91415801c44.99525866-0.76783723 81.39074114-16.58528313 108.26504222-46.83806846 59.12346278-66.6482671 49.29514673-180.9024399 48.2201749-191.80572705-3.83918618-81.85144307-42.53817965-121.01113989-74.48020676-139.28566479C572.42878628 149.19693275 544.63307997 141.82569571 513.61245812 141.21142578H511.00181181c-17.04598575 0-50.52368662 2.76421361-82.61928101 21.03873851-32.24916171 18.27452489-71.56242513 57.43422101-75.40161058 139.89993473-1.07497185 10.90328787-10.90328787 125.15746067 48.22017491 191.80572704 26.72073376 30.2527846 63.11621625 46.07023049 108.11147491 46.83806846z m-115.32914464-234.80461006c0-0.46070263 0.15356731-0.92140454 0.15356731-1.22853914 5.06772532-110.10785129 83.23355022-121.93254442 116.7112518-121.93254443H512.69105358c41.4632078 0.92140454 111.95066108 17.81382227 116.71125108 121.93254443 0 0.46070263 0 0.92140454 0.15356731 1.22853914 0.15356731 1.07497185 10.90328787 105.50082861-37.93115624 160.47797059-19.34949674 21.80657575-45.14882595 32.55629632-79.08722946 32.86343166h-1.53567446c-33.78483618-0.30713461-59.73773271-11.05685517-78.93366214-32.86343165-48.68087753-54.67000739-38.23829157-159.55656606-38.08472427-160.4779706z" p-id="1898"></path><path d="M827.3507296 730.29611058v-0.46070263c0-1.22853915-0.15356731-2.457079-0.15356731-3.83918618-0.92140454-30.40635263-2.91778164-101.50807511-69.56604874-124.23605539-0.46070263-0.15356731-1.07497185-0.30713461-1.53567375-0.46070264-69.25891341-17.66025497-126.84670245-57.5877883-127.46097237-58.04849021-9.3676134-6.60339979-22.26727838-4.29988808-28.87067744 5.06772532-6.60339979 9.3676134-4.29988808 22.26727838 5.0677253 28.87067743 2.6106463 1.84280907 63.73048619 44.38098873 140.20706862 64.03762079 35.78121256 12.74609694 39.77396603 50.98438852 40.84893787 85.99776458 0 1.38210717 0 2.6106463 0.15356802 3.83918545 0.15356731 13.82106951-0.76783723 35.16694264-3.22491624 47.45233838-24.87792469 14.12820412-122.39324633 62.96264895-270.73938991 62.96264823-147.73187366 0-245.86146522-48.98801213-270.89295721-63.11621625-2.457079-12.28539504-3.53205084-33.63126816-3.22491624-47.45233767 0-1.22853915 0.15356731-2.457079 0.15356802-3.83918545 1.07497185-35.01337533 5.06772532-73.25166689 40.84893786-85.99776457 76.47658314-19.65663206 137.596423-62.34837901 140.20706862-64.03762079 9.3676134-6.60339979 11.67112511-19.50306404 5.06772531-28.87067744-6.60339979-9.3676134-19.50306404-11.67112511-28.87067745-5.06772605-0.61426993 0.46070263-57.89492363 40.38823598-127.46097236 58.04849094-0.61426993 0.15356731-1.07497185 0.30713461-1.53567375 0.46070264-66.6482671 22.88154832-68.64464421 93.9832708-69.56604874 124.2360554 0 1.38210717 0 2.6106463-0.15356731 3.83918617v0.46070191c-0.15356731 7.98550697-0.30713461 48.98801213 7.83193893 69.56604875 1.53567446 3.99275348 4.29988808 7.37123702 7.98550697 9.67474873 4.60702342 3.07134893 115.02200931 73.4052342 299.76363465 73.40523419s295.15661197-70.48745329 299.76363538-73.40523419c3.53205084-2.3035117 6.44983249-5.68199525 7.98550624-9.67474873 7.67837163-20.4244693 7.52480433-61.42697448 7.37123703-69.41248072z" p-id="1899"></path></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
public/gms-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
public/mobifont-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
public/sgw-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
public/smatec-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
public/spole-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

131
src/access.ts Normal file
View File

@@ -0,0 +1,131 @@
import { InitialStateResponse } from './app';
import { SGW_ROLE } from './constants/slave/sgw';
export default (initialState: InitialStateResponse) => {
// Lấy role của user hiện tại
const userType = initialState?.currentUserProfile?.metadata?.user_type;
// Lấy biến môi trường domain
const env = process.env.DOMAIN_ENV;
// Khởi tạo object quyền mặc định (tất cả là false)
let permissions = {
canAdmin: false,
canAdmin_SysAdmin: false,
canEndUser_Admin: false,
canEndUser_User_Admin: false,
canEndUser_User: false,
canEndUser: false,
canAll: true,
// Cờ đánh dấu Domain hiện tại
isSGW: env === 'sgw' || !env,
isGMS: env === 'gms',
isSpole: env === 'spole',
};
// Switch case để xử lý logic quyền riêng cho từng Domain
switch (env) {
case 'gms':
if (userType) {
permissions.canAdmin = userType === SGW_ROLE.ADMIN;
permissions.canAdmin_SysAdmin =
userType === SGW_ROLE.SYSADMIN || userType === SGW_ROLE.ADMIN;
permissions.canEndUser_Admin =
userType === SGW_ROLE.ENDUSER || userType === SGW_ROLE.ADMIN;
permissions.canEndUser_User_Admin = [
SGW_ROLE.ENDUSER,
SGW_ROLE.USERS,
SGW_ROLE.ADMIN,
].includes(userType as SGW_ROLE);
permissions.canEndUser_User = [
SGW_ROLE.ENDUSER,
SGW_ROLE.USERS,
].includes(userType as SGW_ROLE);
permissions.canEndUser = userType === SGW_ROLE.ENDUSER;
}
break;
case 'spole':
if (userType) {
permissions.canAdmin = userType === SGW_ROLE.ADMIN;
permissions.canAdmin_SysAdmin =
userType === SGW_ROLE.SYSADMIN || userType === SGW_ROLE.ADMIN;
permissions.canEndUser_Admin =
userType === SGW_ROLE.ENDUSER || userType === SGW_ROLE.ADMIN;
permissions.canEndUser_User_Admin = [
SGW_ROLE.ENDUSER,
SGW_ROLE.USERS,
SGW_ROLE.ADMIN,
].includes(userType as SGW_ROLE);
permissions.canEndUser_User = [
SGW_ROLE.ENDUSER,
SGW_ROLE.USERS,
].includes(userType as SGW_ROLE);
permissions.canEndUser = userType === SGW_ROLE.ENDUSER;
}
break;
case 'sgw':
if (userType) {
permissions.canAdmin = userType === SGW_ROLE.ADMIN;
permissions.canAdmin_SysAdmin =
userType === SGW_ROLE.SYSADMIN || userType === SGW_ROLE.ADMIN;
permissions.canEndUser_Admin =
userType === SGW_ROLE.ENDUSER || userType === SGW_ROLE.ADMIN;
permissions.canEndUser_User_Admin = [
SGW_ROLE.ENDUSER,
SGW_ROLE.USERS,
SGW_ROLE.ADMIN,
].includes(userType as SGW_ROLE);
permissions.canEndUser_User = [
SGW_ROLE.ENDUSER,
SGW_ROLE.USERS,
].includes(userType as SGW_ROLE);
permissions.canEndUser = userType === SGW_ROLE.ENDUSER;
}
break;
default:
// Logic phân quyền cho SGW
if (userType) {
permissions.canAdmin = userType === SGW_ROLE.ADMIN;
permissions.canAdmin_SysAdmin =
userType === SGW_ROLE.SYSADMIN || userType === SGW_ROLE.ADMIN;
permissions.canEndUser_Admin =
userType === SGW_ROLE.ENDUSER || userType === SGW_ROLE.ADMIN;
permissions.canEndUser_User_Admin = [
SGW_ROLE.ENDUSER,
SGW_ROLE.USERS,
SGW_ROLE.ADMIN,
].includes(userType as SGW_ROLE);
permissions.canEndUser_User = [
SGW_ROLE.ENDUSER,
SGW_ROLE.USERS,
].includes(userType as SGW_ROLE);
permissions.canEndUser = userType === SGW_ROLE.ENDUSER;
}
break;
}
return permissions;
};

153
src/app.tsx Normal file
View File

@@ -0,0 +1,153 @@
// 运行时配置
import { getLocale, history, Link, RunTimeLayoutConfig } from '@umijs/max';
import { ConfigProvider } from 'antd';
import dayjs from 'dayjs';
import { handleRequestConfig as devRequestConfig } from '../config/request_dev';
import { handleRequestConfig as prodRequestConfig } from '../config/request_prod';
import { AvatarDropdown } from './components/Avatar/AvatarDropdown';
import IconFont from './components/IconFont';
import LanguageSwitcher from './components/Lang/LanguageSwitcher';
import ThemeProvider from './components/Theme/ThemeProvider';
import ThemeSwitcher from './components/Theme/ThemeSwitcher';
import { THEME_KEY } from './constants';
import { ROUTE_LOGIN } from './constants/routes';
import NotFoundPage from './pages/Exception/NotFound';
import UnAccessPage from './pages/Exception/UnAccess';
import { apiQueryProfile } from './services/master/AuthController';
import { checkTokenExpired } from './utils/jwt';
import { getLogoImage } from './utils/logo';
import {
clearAllData,
clearSessionData,
getToken,
removeToken,
} from './utils/storage';
const isProdBuild = process.env.NODE_ENV === 'production';
export type InitialStateResponse = {
getUserProfile?: () => Promise<MasterModel.ProfileResponse | undefined>;
currentUserProfile?: MasterModel.ProfileResponse;
theme?: 'light' | 'dark';
};
// 全局初始化数据配置,用于 Layout 用户信息和权限初始化
// 更多信息见文档https://umijs.org/docs/api/runtime-config#getinitialstate
export async function getInitialState(): Promise<InitialStateResponse> {
const userToken: string = getToken();
const { pathname } = history.location;
dayjs.locale(getLocale() === 'en-US' ? 'en' : 'vi');
if (!userToken) {
if (pathname !== ROUTE_LOGIN) {
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
} else {
history.push(ROUTE_LOGIN);
}
return {};
}
const isTokenExpried = checkTokenExpired(userToken);
if (isTokenExpried) {
removeToken();
clearAllData();
clearSessionData();
window.location.href = ROUTE_LOGIN;
return {};
}
const getUserProfile = async () => {
try {
const resp = await apiQueryProfile();
return resp;
} catch (error) {
removeToken();
clearAllData();
clearSessionData();
window.location.href = ROUTE_LOGIN;
}
};
const resp = await getUserProfile();
const currentTheme =
(localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light';
return {
getUserProfile: getUserProfile!,
currentUserProfile: resp,
theme: currentTheme,
};
}
export const layout: RunTimeLayoutConfig = ({ initialState }) => {
const isDark = initialState?.theme === 'dark';
return {
logo: getLogoImage(),
menu: {
locale: true,
},
fixedHeader: true,
contentWidth: 'Fluid',
navTheme: isDark ? 'realDark' : 'light',
splitMenus: true,
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_y5mqyqd86f.js',
contentStyle: {
padding: 0,
margin: 0,
paddingInline: 0,
},
actionsRender: () => [
<ThemeSwitcher key="theme-switcher" />,
<LanguageSwitcher key="lang-switcher" type="dropdown" />,
],
avatarProps: {
size: 'small',
src: '/avatar.svg',
render: () => (
<AvatarDropdown currentUserProfile={initialState?.currentUserProfile} />
),
},
childrenRender: (children) => {
return (
<ThemeProvider>
<ConfigProvider
theme={{
components: {
Collapse: {
contentPadding: '0px',
borderlessContentPadding: '0px',
headerPadding: '0px',
},
},
}}
>
{children}
</ConfigProvider>
</ThemeProvider>
);
},
layout: 'top',
menuHeaderRender: undefined,
menuItemRender: (item, dom) => {
if (item.path) {
// Coerce values to string to satisfy TypeScript expectations
const to = String(item.path ?? '');
const iconType = String(item.icon ?? '');
const label = String(item.name ?? '');
return (
<Link to={to}>
<IconFont type={iconType} />
<span>{label}</span>
</Link>
);
}
return dom;
},
token: {
header: {
colorBgMenuItemSelected: '#EEF7FF', // background khi chọn
colorTextMenuSelected: isDark ? '#fff' : '#1A2130', // màu chữ khi chọn
},
},
unAccessible: <UnAccessPage />,
noFound: <NotFoundPage />,
};
};
export const request = isProdBuild ? prodRequestConfig : devRequestConfig;

0
src/assets/.gitkeep Normal file
View File

View File

@@ -0,0 +1,61 @@
import { ROUTE_LOGIN, ROUTE_PROFILE } from '@/constants/routes';
import { clearAllData, clearSessionData, removeToken } from '@/utils/storage';
import { LogoutOutlined, ProfileOutlined } from '@ant-design/icons';
import { history, useIntl } from '@umijs/max';
import { Dropdown } from 'antd';
// Avatar component with i18n support
export const AvatarDropdown = ({
currentUserProfile,
}: {
currentUserProfile?: MasterModel.ProfileResponse;
}) => {
const intl = useIntl();
return (
<Dropdown
menu={{
items: [
{
key: ROUTE_PROFILE,
icon: <ProfileOutlined />,
label: intl.formatMessage({ id: 'menu.profile' }),
onClick: () => {
history.push(ROUTE_PROFILE);
},
},
{
type: 'divider',
},
{
key: 'logout',
icon: <LogoutOutlined />,
label: intl.formatMessage({ id: 'common.logout' }),
onClick: () => {
removeToken();
clearAllData();
clearSessionData();
window.location.href = ROUTE_LOGIN;
},
},
],
}}
>
<div className="flex gap-2">
<img
src="/avatar.svg"
alt="avatar"
style={{
width: 32,
height: 32,
borderRadius: '50%',
cursor: 'pointer',
}}
/>
<span className="font-bold">
{currentUserProfile?.metadata?.full_name || ''}
</span>
</div>
</Dropdown>
);
};

View File

@@ -0,0 +1,47 @@
import { DefaultFooter } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import packageJson from '../../../package.json';
import './style.less';
const Footer = () => {
const intl = useIntl();
const defaultMessage = intl.formatMessage({
id: 'app.copyright.produced',
defaultMessage: 'by SMATEC Team',
});
const getDomainVersion = () => {
switch (process.env.DOMAIN_ENV) {
case 'gms':
return packageJson['gms-version'];
case 'sgw':
return packageJson['sgw-version'];
case 'spole':
return packageJson['spole-version'];
default:
return '-';
}
};
const currentYear = new Date().getFullYear();
return (
<DefaultFooter
style={{
background: 'none',
}}
copyright={`${currentYear} ${defaultMessage} v${getDomainVersion()}`}
links={
[
// {
// key: 'smatec',
// title: 'Smatec JSC',
// href: 'https://smatec.com.vn',
// blankTarget: true,
// }
]
}
/>
);
};
export default Footer;

View File

@@ -0,0 +1,3 @@
.ant-pro-global-footer-copyright {
color: white !important; /* hoặc mã màu bạn muốn */
}

View File

@@ -0,0 +1,4 @@
.title {
margin: 0 auto;
font-weight: 200;
}

View File

@@ -0,0 +1,23 @@
import { Layout, Row, Typography } from 'antd';
import React from 'react';
import styles from './Guide.less';
interface Props {
name: string;
}
// 脚手架示例组件
const Guide: React.FC<Props> = (props) => {
const { name } = props;
return (
<Layout>
<Row>
<Typography.Title level={3} className={styles.title}>
使 <strong>{name}</strong>
</Typography.Title>
</Row>
</Layout>
);
};
export default Guide;

View File

@@ -0,0 +1,2 @@
import Guide from './Guide';
export default Guide;

View File

@@ -0,0 +1,7 @@
import { createFromIconfontCN } from '@ant-design/icons';
const IconFont = createFromIconfontCN({
scriptUrl: '//at.alicdn.com/t/c/font_5096559_y5mqyqd86f.js',
});
export default IconFont;

View File

@@ -0,0 +1,93 @@
import { getLocale, setLocale, useIntl } from '@umijs/max';
import { Button, Dropdown, Space } from 'antd';
import React from 'react';
export interface LanguageSwitcherProps {
className?: string;
type?: 'dropdown' | 'button';
size?: 'small' | 'middle' | 'large';
}
const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({
className,
type = 'dropdown',
size = 'middle',
}) => {
const intl = useIntl();
const languageItems = [
{
key: 'vi-VN',
label: intl.formatMessage({ id: 'common.vietnamese' }),
flag: '🇻🇳',
},
{
key: 'en-US',
label: intl.formatMessage({ id: 'common.english' }),
flag: '🇺🇸',
},
];
const handleLanguageChange = (locale: string) => {
setLocale(locale, false);
};
const getCurrentLanguage = () => {
const currentLocale = intl.locale || getLocale() || 'vi-VN';
return (
languageItems.find((item) => item.key === currentLocale) ||
languageItems[0]
);
};
const dropdownItems = languageItems.map((item) => ({
key: item.key,
label: (
<Space>
<span>{item.flag}</span>
<span>{item.label}</span>
</Space>
),
onClick: () => handleLanguageChange(item.key),
}));
if (type === 'button') {
const currentLang = getCurrentLanguage();
return (
<Button
size={size}
onClick={() => {
const nextLocale = currentLang.key === 'vi-VN' ? 'en-US' : 'vi-VN';
handleLanguageChange(nextLocale);
}}
className={className}
>
<Space>
<span>{currentLang.flag}</span>
<span>{currentLang.label}</span>
</Space>
</Button>
);
}
const currentLang = getCurrentLanguage();
return (
<Dropdown
menu={{ items: dropdownItems }}
placement="bottomRight"
trigger={['click']}
className={className}
>
<Button size={size} type="text">
<Space>
<span>{currentLang.flag}</span>
<span>{currentLang.label}</span>
</Space>
</Button>
</Dropdown>
);
};
export default LanguageSwitcher;

View File

@@ -0,0 +1,43 @@
import { getLocale, setLocale } from '@umijs/max';
import { useEffect, useState } from 'react';
const LangSwitches = () => {
const [isEnglish, setIsEnglish] = useState(false);
// Keep checkbox in sync with current Umi locale without forcing a reload
useEffect(() => {
const syncLocale = () => setIsEnglish(getLocale() === 'en-US');
syncLocale();
window.addEventListener('languagechange', syncLocale);
return () => {
window.removeEventListener('languagechange', syncLocale);
};
}, []);
const handleLanguageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newLocale = e.target.checked ? 'en-US' : 'vi-VN';
setIsEnglish(e.target.checked);
setLocale(newLocale, false); // Update locale instantly without full page reload
};
return (
<label className="relative inline-flex items-center cursor-pointer">
<input
className="sr-only peer"
type="checkbox"
checked={isEnglish}
onChange={handleLanguageChange}
/>
<div
className="w-[70px] h-9 rounded-full bg-gradient-to-r from-orange-400 to-red-400 peer-checked:from-blue-400 peer-checked:to-indigo-500
transition-all duration-500 after:content-['🇻🇳'] after:absolute after:top-1 peer-checked:left-0 after:left-1 peer-checked:after:left-[-8px] after:bg-white after:rounded-full after:h-7
after:w-7 after:flex after:items-center after:justify-center after:transition-all after:duration-500 peer-checked:after:translate-x-10
peer-checked:after:content-['🏴󠁧󠁢󠁥󠁮󠁧󠁿'] after:shadow-md after:text-2xl"
></div>
</label>
);
};
export default LangSwitches;

View File

@@ -0,0 +1,37 @@
import { ConfigProvider, theme } from 'antd';
import React, { useEffect, useState } from 'react';
import { getTheme } from './ThemeSwitcher';
interface ThemeProviderProps {
children: React.ReactNode;
}
const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const [isDark, setIsDark] = useState(getTheme() === 'dark');
useEffect(() => {
const handleThemeChange = (e: CustomEvent) => {
setIsDark(e.detail.theme === 'dark');
};
window.addEventListener('theme-change', handleThemeChange as EventListener);
return () => {
window.removeEventListener(
'theme-change',
handleThemeChange as EventListener,
);
};
}, []);
return (
<ConfigProvider
theme={{
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
}}
>
{children}
</ConfigProvider>
);
};
export default ThemeProvider;

View File

@@ -0,0 +1,81 @@
import { THEME_KEY } from '@/constants';
import { MoonOutlined, SunOutlined } from '@ant-design/icons';
import { useIntl, useModel } from '@umijs/max';
import { Button, Dropdown } from 'antd';
import React, { useEffect, useState } from 'react';
export interface ThemeSwitcherProps {
className?: string;
}
const ThemeSwitcher: React.FC<ThemeSwitcherProps> = ({ className }) => {
const { initialState, setInitialState } = useModel('@@initialState');
const intl = useIntl();
const [isDark, setIsDark] = useState(
(initialState?.theme as 'light' | 'dark') === 'dark',
);
useEffect(() => {
const handleThemeChange = (e: CustomEvent) => {
setIsDark(e.detail.theme === 'dark');
};
window.addEventListener('theme-change', handleThemeChange as EventListener);
return () => {
window.removeEventListener(
'theme-change',
handleThemeChange as EventListener,
);
};
}, []);
const handleThemeChange = (newTheme: 'light' | 'dark') => {
localStorage.setItem(THEME_KEY, newTheme);
// Update global state để trigger layout re-render
setInitialState({
...initialState,
theme: newTheme,
} as any).then(() => {
// Dispatch event để notify ThemeProvider
window.dispatchEvent(
new CustomEvent('theme-change', { detail: { theme: newTheme } }),
);
});
};
const items = [
{
key: 'light',
label: intl.formatMessage({
id: 'common.theme.light',
defaultMessage: 'Light Theme',
}),
icon: <SunOutlined />,
onClick: () => handleThemeChange('light'),
},
{
key: 'dark',
label: intl.formatMessage({
id: 'common.theme.dark',
defaultMessage: 'Dark Theme',
}),
icon: <MoonOutlined />,
onClick: () => handleThemeChange('dark'),
},
];
return (
<Dropdown menu={{ items }} placement="bottomRight" trigger={['click']}>
<Button type="text" className={className}>
{isDark ? <MoonOutlined /> : <SunOutlined />}
</Button>
</Dropdown>
);
};
export default ThemeSwitcher;
// Helper function để get theme từ localStorage
export const getTheme = (): 'light' | 'dark' => {
return (localStorage.getItem(THEME_KEY) as 'light' | 'dark') || 'light';
};

View File

@@ -0,0 +1,69 @@
import { THEME_KEY } from '@/constants';
import React, { useEffect, useState } from 'react';
import { getTheme } from './ThemeSwitcher';
import './style.less';
const ThemeSwitcherAuth = () => {
const [isDark, setIsDark] = useState(getTheme() === 'dark');
useEffect(() => {
const handleThemeChange = (e: CustomEvent) => {
setIsDark(e.detail.theme === 'dark');
};
window.addEventListener('theme-change', handleThemeChange as EventListener);
return () => {
window.removeEventListener(
'theme-change',
handleThemeChange as EventListener,
);
};
}, []);
const handleSwitch = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTheme = e.target.checked ? 'dark' : 'light';
localStorage.setItem(THEME_KEY, newTheme);
setIsDark(newTheme === 'dark');
window.dispatchEvent(
new CustomEvent('theme-change', { detail: { theme: newTheme } }),
);
};
return (
<label className="theme-switch">
<input
type="checkbox"
className="theme-switch-checkbox"
checked={isDark}
onChange={handleSwitch}
/>
<div className="theme-switch-container">
<div className="theme-switch-clouds"></div>
<div className="theme-switch-stars-container">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 144 55"
fill="none"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M135.831 3.00688C135.055 3.85027 134.111 4.29946 133 4.35447C134.111 4.40947 135.055 4.85867 135.831 5.71123C136.607 6.55462 136.996 7.56303 136.996 8.72727C136.996 7.95722 137.172 7.25134 137.525 6.59129C137.886 5.93124 138.372 5.39954 138.98 5.00535C139.598 4.60199 140.268 4.39114 141 4.35447C139.88 4.2903 138.936 3.85027 138.16 3.00688C137.384 2.16348 136.996 1.16425 136.996 0C136.996 1.16425 136.607 2.16348 135.831 3.00688ZM31 23.3545C32.1114 23.2995 33.0551 22.8503 33.8313 22.0069C34.6075 21.1635 34.9956 20.1642 34.9956 19C34.9956 20.1642 35.3837 21.1635 36.1599 22.0069C36.9361 22.8503 37.8798 23.2903 39 23.3545C38.2679 23.3911 37.5976 23.602 36.9802 24.0053C36.3716 24.3995 35.8864 24.9312 35.5248 25.5913C35.172 26.2513 34.9956 26.9572 34.9956 27.7273C34.9956 26.563 34.6075 25.5546 33.8313 24.7112C33.0551 23.8587 32.1114 23.4095 31 23.3545ZM0 36.3545C1.11136 36.2995 2.05513 35.8503 2.83131 35.0069C3.6075 34.1635 3.99559 33.1642 3.99559 32C3.99559 33.1642 4.38368 34.1635 5.15987 35.0069C5.93605 35.8503 6.87982 36.2903 8 36.3545C7.26792 36.3911 6.59757 36.602 5.98015 37.0053C5.37155 37.3995 4.88644 37.9312 4.52481 38.5913C4.172 39.2513 3.99559 39.9572 3.99559 40.7273C3.99559 39.563 3.6075 38.5546 2.83131 37.7112C2.05513 36.8587 1.11136 36.4095 0 36.3545ZM56.8313 24.0069C56.0551 24.8503 55.1114 25.2995 54 25.3545C55.1114 25.4095 56.0551 25.8587 56.8313 26.7112C57.6075 27.5546 57.9956 28.563 57.9956 29.7273C57.9956 28.9572 58.172 28.2513 58.5248 27.5913C58.8864 26.9312 59.3716 26.3995 59.9802 26.0053C60.5976 25.602 61.2679 25.3911 62 25.3545C60.8798 25.2903 59.9361 24.8503 59.1599 24.0069C58.3837 23.1635 57.9956 22.1642 57.9956 21C57.9956 22.1642 57.6075 23.1635 56.8313 24.0069ZM81 25.3545C82.1114 25.2995 83.0551 24.8503 83.8313 24.0069C84.6075 23.1635 84.9956 22.1642 84.9956 21C84.9956 22.1642 85.3837 23.1635 86.1599 24.0069C86.9361 24.8503 87.8798 25.2903 89 25.3545C88.2679 25.3911 87.5976 25.602 86.9802 26.0053C86.3716 26.3995 85.8864 26.9312 85.5248 27.5913C85.172 28.2513 84.9956 28.9572 84.9956 29.7273C84.9956 28.563 84.6075 27.5546 83.8313 26.7112C83.0551 25.8587 82.1114 25.4095 81 25.3545ZM136 36.3545C137.111 36.2995 138.055 35.8503 138.831 35.0069C139.607 34.1635 139.996 33.1642 139.996 32C139.996 33.1642 140.384 34.1635 141.16 35.0069C141.936 35.8503 142.88 36.2903 144 36.3545C143.268 36.3911 142.598 36.602 141.98 37.0053C141.372 37.3995 140.886 37.9312 140.525 38.5913C140.172 39.2513 139.996 39.9572 139.996 40.7273C139.996 39.563 139.607 38.5546 138.831 37.7112C138.055 36.8587 137.111 36.4095 136 36.3545ZM101.831 49.0069C101.055 49.8503 100.111 50.2995 99 50.3545C100.111 50.4095 101.055 50.8587 101.831 51.7112C102.607 52.5546 102.996 53.563 102.996 54.7273C102.996 53.9572 103.172 53.2513 103.525 52.5913C103.886 51.9312 104.372 51.3995 104.98 51.0053C105.598 50.602 106.268 50.3911 107 50.3545C105.88 50.2903 104.936 49.8503 104.16 49.0069C103.384 48.1635 102.996 47.1642 102.996 46C102.996 47.1642 102.607 48.1635 101.831 49.0069Z"
fill="currentColor"
></path>
</svg>
</div>
<div className="theme-switch-circle-container">
<div className="theme-switch-sun-moon-container">
<div className="theme-switch-moon">
<div className="theme-switch-spot"></div>
<div className="theme-switch-spot"></div>
<div className="theme-switch-spot"></div>
</div>
</div>
</div>
</div>
</label>
);
};
export default ThemeSwitcherAuth;

View File

@@ -0,0 +1,202 @@
/* From Uiverse.io by Galahhad */
.theme-switch {
--toggle-size: 20px;
--container-width: 3.75em;
--container-height: 1.7em;
--container-radius: 3.5em;
--container-light-bg: #3d7eae;
--container-night-bg: #1d1f2c;
--circle-container-diameter: 2.1em;
--sun-moon-diameter: 1.3em;
--sun-bg: #ecca2f;
--moon-bg: #c4c9d1;
--spot-color: #959db1;
--circle-container-offset: calc(
(var(--circle-container-diameter) - var(--container-height)) / 2 * -1
);
--stars-color: #fff;
--clouds-color: #f3fdff;
--back-clouds-color: #aacadf;
--transition: 0.5s cubic-bezier(0, -0.02, 0.4, 1.25);
--circle-transition: 0.3s cubic-bezier(0, -0.02, 0.35, 1.17);
}
.theme-switch,
.theme-switch *,
.theme-switch *::before,
.theme-switch *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
font-size: var(--toggle-size);
}
.theme-switch-container {
width: var(--container-width);
height: var(--container-height);
background-color: var(--container-light-bg);
border-radius: var(--container-radius);
overflow: hidden;
cursor: pointer;
box-shadow: 0 -0.062em 0.062em rgba(0, 0, 0, 25%),
0 0.062em 0.125em rgba(255, 255, 255, 94%);
transition: var(--transition);
position: relative;
}
.theme-switch-container::before {
content: '';
position: absolute;
z-index: 1;
inset: 0;
box-shadow: 0 0.05em 0.187em rgba(0, 0, 0, 25%) inset;
border-radius: var(--container-radius);
}
.theme-switch-checkbox {
display: none;
}
.theme-switch-circle-container {
width: var(--circle-container-diameter);
height: var(--circle-container-diameter);
background-color: rgba(255, 255, 255, 10%);
position: absolute;
left: var(--circle-container-offset);
top: var(--circle-container-offset);
border-radius: var(--container-radius);
box-shadow: inset 0 0 0 3.375em rgba(255, 255, 255, 10%),
0 0 0 0.625em rgba(255, 255, 255, 10%),
0 0 0 1.25em rgba(255, 255, 255, 10%);
display: flex;
transition: var(--circle-transition);
pointer-events: none;
}
.theme-switch-sun-moon-container {
pointer-events: auto;
position: relative;
z-index: 2;
width: var(--sun-moon-diameter);
height: var(--sun-moon-diameter);
margin: auto;
border-radius: var(--container-radius);
background-color: var(--sun-bg);
box-shadow: 0.062em 0.062em 0.062em 0 rgba(254, 255, 239, 61%) inset,
0 -0.062em 0.062em 0 #a1872a inset;
filter: drop-shadow(0.062em 0.125em 0.125em rgba(0, 0, 0, 25%))
drop-shadow(0 0.062em 0.125em rgba(0, 0, 0, 25%));
overflow: hidden;
transition: var(--transition);
}
.theme-switch-moon {
transform: translateX(100%);
width: 100%;
height: 100%;
background-color: var(--moon-bg);
border-radius: inherit;
box-shadow: 0.062em 0.062em 0.062em 0 rgba(254, 255, 239, 61%) inset,
0 -0.062em 0.062em 0 #969696 inset;
transition: var(--transition);
position: relative;
}
.theme-switch-spot {
position: absolute;
top: 0.75em;
left: 0.312em;
width: 0.75em;
height: 0.75em;
border-radius: var(--container-radius);
background-color: var(--spot-color);
box-shadow: 0 0.0312em 0.062em rgba(0, 0, 0, 25%) inset;
}
.theme-switch-spot:nth-of-type(2) {
width: 0.375em;
height: 0.375em;
top: 0.937em;
left: 1.375em;
}
.theme-switch-spot:nth-last-of-type(3) {
width: 0.25em;
height: 0.25em;
top: 0.312em;
left: 0.812em;
}
.theme-switch-clouds {
width: 1.25em;
height: 1.25em;
background-color: var(--clouds-color);
border-radius: var(--container-radius);
position: absolute;
bottom: -0.625em;
left: 0.312em;
box-shadow: 0.937em 0.312em var(--clouds-color),
-0.312em -0.312em var(--back-clouds-color),
1.437em 0.375em var(--clouds-color), 0.5em -0.125em var(--back-clouds-color),
2.187em 0 var(--clouds-color), 1.25em -0.062em var(--back-clouds-color),
2.937em 0.312em var(--clouds-color), 2em -0.312em var(--back-clouds-color),
3.625em -0.062em var(--clouds-color), 2.625em 0 var(--back-clouds-color),
4.5em -0.312em var(--clouds-color),
3.375em -0.437em var(--back-clouds-color),
4.625em -1.75em 0 0.437em var(--clouds-color),
4em -0.625em var(--back-clouds-color),
4.125em -2.125em 0 0.437em var(--back-clouds-color);
transition: var(--transition);
}
.theme-switch-stars-container {
position: absolute;
color: var(--stars-color);
top: -100%;
left: 0.312em;
width: 2.75em;
height: auto;
transition: var(--transition);
}
/* actions */
.theme-switch-checkbox:checked + .theme-switch-container {
background-color: var(--container-night-bg);
}
.theme-switch-checkbox:checked
+ .theme-switch-container
.theme-switch-circle-container {
left: calc(
100% - var(--circle-container-offset) - var(--circle-container-diameter)
);
}
.theme-switch-checkbox:checked
+ .theme-switch-container
.theme-switch-circle-container:hover {
left: calc(
100% - var(--circle-container-offset) - var(--circle-container-diameter) -
0.187em
);
}
.theme-switch-circle-container:hover {
left: calc(var(--circle-container-offset) + 0.187em);
}
.theme-switch-checkbox:checked + .theme-switch-container .theme-switch-moon {
transform: translate(0);
}
.theme-switch-checkbox:checked + .theme-switch-container .theme-switch-clouds {
bottom: -4.062em;
}
.theme-switch-checkbox:checked
+ .theme-switch-container
.theme-switch-stars-container {
top: 50%;
transform: translateY(-50%);
}

View File

@@ -0,0 +1,196 @@
import { DEFAULT_PAGE_SIZE, DURATION_POLLING } from '@/constants';
import { apiSearchThings } from '@/services/master/ThingController';
import {
ActionType,
ProCard,
ProColumns,
ProTable,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Grid, Modal, Typography } from 'antd';
import { useEffect, useRef, useState } from 'react';
import TreeGroup from './TreeGroup';
const { Paragraph } = Typography;
type ThingsFilterProps = {
isOpen?: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
thingIds?: string | string[] | null;
onSubmit?: (thingIds: string[]) => void;
};
const ThingsFilter = ({
isOpen,
setIsOpen,
thingIds,
onSubmit,
}: ThingsFilterProps) => {
const intl = useIntl();
const { useBreakpoint } = Grid;
const screens = useBreakpoint();
const tableRef = useRef<ActionType>();
const [groupsIds, setGroupIds] = useState<string | string[] | null>(null);
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [isLoading, setIsLoading] = useState(false);
const domain = process.env.DOMAIN_ENV || 'gms';
useEffect(() => {
if (thingIds) {
const ids = Array.isArray(thingIds) ? thingIds : [thingIds];
setSelectedRowKeys(ids);
} else {
setSelectedRowKeys([]);
}
}, [thingIds]);
const handleOk = () => {
onSubmit?.(selectedRowKeys as string[]);
setIsOpen(false);
};
const handleCancel = () => {
setIsOpen(false);
};
const columns: ProColumns<MasterModel.Thing>[] = [
{
key: 'name',
title: <FormattedMessage id="master.thing.name" defaultMessage="Name" />,
dataIndex: 'name',
},
{
key: 'external_id',
title: (
<FormattedMessage
id="master.thing.external_id"
defaultMessage="External ID"
/>
),
dataIndex: 'metadata.external_id',
render: (_, record) =>
record?.metadata?.external_id ? (
<Paragraph
style={{
marginBottom: 0,
verticalAlign: 'middle',
display: 'inline-block',
}}
copyable
>
{record?.metadata?.external_id}
</Paragraph>
) : (
'-'
),
},
{
key: 'address',
title: (
<FormattedMessage id="master.thing.address" defaultMessage="Address" />
),
dataIndex: 'metadata.address',
hideInSearch: true,
render: (_, record) => record?.metadata?.address || '-',
},
];
return (
<Modal
open={isOpen}
centered
width="80%"
onOk={handleOk}
onCancel={handleCancel}
maskClosable={false}
okText={<FormattedMessage id="common.search" defaultMessage="Search" />}
cancelText={
<FormattedMessage id="common.cancel" defaultMessage="Cancel" />
}
>
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard colSpan={{ xs: 24, sm: 8, md: 8, lg: 6, xl: 6 }}>
<TreeGroup
disable={isLoading}
multiple
onSelected={(value) => {
setGroupIds(value);
tableRef.current?.reload();
}}
/>
</ProCard>
<ProCard colSpan={{ xs: 24, sm: 16, md: 16, lg: 18, xl: 18 }}>
<ProTable<MasterModel.Thing>
tableClassName="table-row-select cursor-pointer-row"
actionRef={tableRef}
toolbar={{
title: null,
}}
bordered={true}
polling={DURATION_POLLING * 4} // 1 minute
rowSelection={{
alwaysShowAlert: true,
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
}}
pagination={{
size: 'small',
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
showSizeChanger: true,
pageSizeOptions: ['10', '15', '20'],
showTotal: (total, range) =>
`${range[0]}-${range[1]} ${intl.formatMessage({
id: 'common.paginations.of',
})} ${total} ${intl.formatMessage({
id: 'common.paginations.things',
defaultMessage: 'things',
})}`,
}}
columns={columns}
request={async (params) => {
setIsLoading(true);
const { current = 1, pageSize, name, external_id } = params;
const size = pageSize || DEFAULT_PAGE_SIZE * 2;
const offset = current === 1 ? 0 : (current - 1) * size;
const query: MasterModel.SearchThingPaginationBody = {
offset: offset,
limit: size,
order: 'name',
dir: 'asc',
};
if (name) {
query.name = name;
}
if (!query.metadata) query.metadata = {};
if (groupsIds)
query.metadata.group_id = Array.isArray(groupsIds)
? groupsIds.join(',')
: groupsIds || undefined;
if (external_id) {
query.metadata = {
...query.metadata,
external_id: external_id,
};
}
const resp = await apiSearchThings(query, domain);
setIsLoading(false);
return {
data: resp.things || [],
success: true,
total: resp.total || 0,
};
}}
options={{
search: false,
setting: false,
density: false,
reload: true,
}}
defaultSize="small"
rowKey="id"
dateFormatter="string"
/>
</ProCard>
</ProCard>
</Modal>
);
};
export default ThingsFilter;

View File

@@ -0,0 +1,139 @@
import { useModel } from '@umijs/max';
import { Tree } from 'antd';
import type { DataNode } from 'antd/es/tree';
import { useEffect, useState } from 'react';
interface TreeGroupProps {
groupIds?: string | string[] | null;
multiple?: boolean;
allowedGroupIds?: string[];
disable?: boolean;
onSelected?: (value: string | string[] | null) => void;
titleRender?: (nodeData: DataNode) => React.ReactNode;
}
const TreeGroup = ({
groupIds,
multiple = false,
allowedGroupIds,
disable = false,
onSelected,
titleRender,
}: TreeGroupProps) => {
const { initialState } = useModel('@@initialState');
const { currentUserProfile } = initialState || {};
const [treeData, setTreeData] = useState<DataNode[]>([]);
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
const { groups, getGroups } = useModel('master.useGroups');
const filterGroupsByAllowedIds = (
groups: MasterModel.GroupNode[],
allowedIds: Set<string>,
): MasterModel.GroupNode[] => {
return groups
.map((group) => {
const filteredChildren = group.children
? filterGroupsByAllowedIds(group.children, allowedIds)
: [];
if (allowedIds.has(group.id) || filteredChildren.length > 0) {
return {
...group,
children: filteredChildren,
};
}
return null;
})
.filter(Boolean) as MasterModel.GroupNode[];
};
const transformGroupNodeToTreeData = (
groups: MasterModel.GroupNode[],
): DataNode[] =>
groups.map((group) => ({
title: group.name,
key: group.id,
children: group.children?.length
? transformGroupNodeToTreeData(group.children)
: undefined,
}));
useEffect(() => {
if (!groups) return;
let displayGroups = groups;
if (allowedGroupIds && allowedGroupIds.length > 0) {
const allowedSet = new Set(allowedGroupIds);
displayGroups = filterGroupsByAllowedIds(groups, allowedSet);
}
setTreeData(transformGroupNodeToTreeData(displayGroups));
}, [groups, allowedGroupIds]);
useEffect(() => {
if (!groups) {
getGroups();
}
}, [groups]);
useEffect(() => {
const loadTreeData = async () => {
const transformedData = transformGroupNodeToTreeData(groups || []);
setTreeData(transformedData);
};
loadTreeData();
}, [currentUserProfile, groups]);
useEffect(() => {
// Set selected keys from groupIds prop
if (groupIds) {
setSelectedKeys(Array.isArray(groupIds) ? groupIds : [groupIds]);
} else {
setSelectedKeys([]);
}
}, [groupIds]);
const handleSelect = (selectedKeys: React.Key[]) => {
const keys = selectedKeys.map((k) => String(k));
setSelectedKeys(selectedKeys);
if (multiple) {
onSelected?.(keys.length > 0 ? keys : null);
} else {
onSelected?.(keys.length > 0 ? keys[0] : null);
}
};
const handleCheck = (
checkedKeys:
| React.Key[]
| { checked: React.Key[]; halfChecked: React.Key[] },
) => {
const keys = Array.isArray(checkedKeys) ? checkedKeys : checkedKeys.checked;
const stringKeys = keys.map((k) => String(k));
setSelectedKeys(keys);
onSelected?.(stringKeys.length > 0 ? stringKeys : null);
};
return (
<Tree
disabled={disable}
checkable={multiple}
treeData={treeData}
checkedKeys={multiple ? selectedKeys : undefined}
selectedKeys={!multiple ? selectedKeys : undefined}
onCheck={multiple ? handleCheck : undefined}
onSelect={!multiple ? handleSelect : undefined}
defaultExpandAll={true}
showLine
autoExpandParent
{...(titleRender && { titleRender })}
/>
);
};
export default TreeGroup;

View File

@@ -0,0 +1,140 @@
import { useIntl, useModel } from '@umijs/max';
import { Spin, TreeSelect } from 'antd';
import { useEffect, useState } from 'react';
interface TreeSelectedGroupProps {
groupIds?: string | string[] | null;
multiple?: boolean;
disabled?: boolean;
isLoading?: boolean;
onSelected?: (value: string | string[] | null) => void;
placeholder?: string;
allowClear?: boolean;
showSearch?: boolean;
style?: React.CSSProperties;
}
const TreeSelectedGroup = ({
groupIds,
multiple = false,
disabled = false,
isLoading = false,
onSelected,
placeholder,
allowClear = true,
showSearch = true,
style = { width: '100%' },
}: TreeSelectedGroupProps) => {
const intl = useIntl();
const { initialState } = useModel('@@initialState');
const { currentUserProfile } = initialState || {};
const [treeData, setTreeData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [selectedValue, setSelectedValue] = useState<string | string[] | null>(
() => {
// If disabled is true, don't show any selected values
if (disabled) {
return null;
}
return groupIds || null;
},
);
const { groups, getGroups } = useModel('master.useGroups');
useEffect(() => {
if (!groups) {
getGroups();
}
}, [groups]);
// Update internal state when prop changes
useEffect(() => {
// If disabled is true, clear the selection
if (disabled) {
setSelectedValue(null);
} else {
setSelectedValue(groupIds || null);
}
}, [groupIds, disabled]);
const transformGroupNodeToTreeData = (
groups: MasterModel.GroupNode[],
): any[] => {
// console.log('Group tranform: ', groups);
// Convert groupIds to array for easier checking
const groupIdsArray = Array.isArray(groupIds)
? groupIds
: groupIds
? [groupIds]
: [];
return groups.map((group: any) => ({
title: group.name,
value: group.id,
key: group.id,
disabled: disabled && groupIdsArray.includes(group.id),
children: group.children
? transformGroupNodeToTreeData(group.children)
: undefined,
data: group,
}));
};
useEffect(() => {
const loadTreeData = async () => {
setLoading(true);
const transformedData = transformGroupNodeToTreeData(groups || []);
setTreeData(transformedData);
setLoading(false);
};
loadTreeData();
}, [currentUserProfile, groups, disabled, groupIds]);
const handleChange = (value: string | string[] | null) => {
setSelectedValue(value);
onSelected?.(value);
};
return (
<Spin spinning={loading}>
<TreeSelect
disabled={isLoading}
value={selectedValue}
multiple={multiple}
treeCheckable={multiple}
showSearch={showSearch}
style={style}
styles={{
popup: {
root: {
maxHeight: 400,
overflow: 'auto',
},
},
}}
placeholder={
placeholder ||
intl.formatMessage({
id: 'master.groups.treeselect.placeholder',
defaultMessage: 'Please select group',
})
}
allowClear={allowClear}
treeDefaultExpandAll
treeData={treeData}
onChange={handleChange}
treeLine
filterTreeNode={(search, node) => {
return (
node.title
?.toString()
.toLowerCase()
.includes(search.toLowerCase()) ?? false
);
}}
/>
</Spin>
);
};
export default TreeSelectedGroup;

22
src/constants/api.ts Normal file
View File

@@ -0,0 +1,22 @@
// Auth API Paths
export const API_PATH_LOGIN = '/api/tokens';
export const API_PATH_GET_PROFILE = '/api/users/profile';
export const API_CHANGE_PASSWORD = '/api/password';
// Alarm API Constants
export const API_ALARMS = '/api/alarms';
export const API_ALARMS_CONFIRM = '/api/alarms/confirm';
// Thing API Constants
export const API_THINGS_SEARCH = '/api/things/search';
// Group API Constants
export const API_GROUPS = '/api/groups';
export const API_GROUP_MEMBERS = '/api/members';
export const API_GROUP_CHILDREN = '/api/groups';
// Log API Constants
export const API_LOGS = '/api/reader/channels';
// User API Constants
export const API_USERS = '/api/users';
export const API_USERS_BY_GROUP = '/api/users/groups';

38
src/constants/index.ts Normal file
View File

@@ -0,0 +1,38 @@
export const DURATION_POLLING = 15000; //15s
export const DURATION_CHART = 300000; //5m
export const DEFAULT_LIMIT = 200;
export const DATE_TIME_FORMAT = 'DD/MM/YYYY HH:mm:ss';
export const TIME_FORMAT = 'HH:mm:ss';
export const DATE_FORMAT = 'DD/MM/YYYY';
export const STATUS_NORMAL = 0;
export const STATUS_WARNING = 1;
export const STATUS_DANGEROUS = 2;
export const STATUS_SOS = 3;
export const COLOR_DISCONNECT = '#d9d9d9';
export const COLOR_NORMAL = '#389e0d';
export const COLOR_WARNING = '#d48806';
export const COLOR_DANGEROUS = '#d9363e';
export const COLOR_SOS = '#ff0000';
export const TOKEN = 'token';
export const THEME_KEY = 'theme';
// Global Constants
export const LIMIT_TREE_LEVEL = 5;
export const DEFAULT_PAGE_SIZE = 5;
export const PADDING_IN_LINE = 4;
export const PADDING_BLOCK = 4;
export enum HTTPSTATUS {
HTTP_SUCCESS = 200,
HTTP_BADREQUEST = 400,
HTTP_UNAUTHORIZED = 401,
HTTP_FORBIDDEN = 403,
HTTP_NOTFOUND = 404,
HTTP_SERVERERROR = 500,
HTTP_ACCEPTED = 202,
HTTP_CREATED = 201,
HTTP_NOCONTENT = 204,
}

3
src/constants/routes.ts Normal file
View File

@@ -0,0 +1,3 @@
export const ROUTE_LOGIN = '/login';
export const ROUTER_HOME = '/';
export const ROUTE_PROFILE = '/profile';

View File

@@ -0,0 +1,6 @@
export enum SGW_ROLE {
SYSADMIN = 'sysadmin',
ADMIN = 'admin',
USERS = 'users',
ENDUSER = 'enduser',
}

View File

@@ -0,0 +1,4 @@
export const SGW_ROUTE_HOME = '/maps';
export const SGW_ROUTE_TRIP = '/trip';
export const SGW_ROUTE_CREATE_BANZONE = '/manager/banzones/create';
export const SGW_ROUTE_MANAGER_BANZONE = '/manager/banzones';

5
src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare namespace NodeJS {
interface ProcessEnv {
DOMAIN_ENV?: 'gms' | 'sgw' | 'spole'; // hoặc các giá trị bạn dùng
}
}

14
src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
/// <reference types="@ant-design/pro-components" />
declare module '@ant-design/pro-components' {
export * from '@ant-design/pro-card';
export * from '@ant-design/pro-descriptions';
export * from '@ant-design/pro-field';
export * from '@ant-design/pro-form';
export * from '@ant-design/pro-layout';
export * from '@ant-design/pro-list';
export * from '@ant-design/pro-provider';
export * from '@ant-design/pro-skeleton';
export * from '@ant-design/pro-table';
export * from '@ant-design/pro-utils';
}

63
src/locales/en-US.ts Normal file
View File

@@ -0,0 +1,63 @@
import masterEn from './en-US/master/master-en';
import gmsEn from './en-US/slave/gms/gms-en';
import sgwEn from './en-US/slave/sgw/sgw-en';
import spoleEn from './en-US/slave/spole/spole-en';
export default {
'app.copyright.produced': 'Produced by Mobifone.',
'common.save': 'Save',
'common.cancel': 'Cancel',
'common.delete': 'Delete',
'common.delete_confirm': 'Confirm delete?',
'common.delete_messgage': 'Do you want to delete',
'common.deleting': 'Deleting...',
'common.edit': 'Edit',
'common.add': 'Add',
'common.actions': 'Actions',
'common.search': 'Search',
'common.filter': 'Filter',
'common.loading': 'Loading...',
'common.updating': 'Updating...',
'common.confirm': 'Confirm',
'common.notification': 'Notification',
'common.back': 'Back',
'common.next': 'Next',
'common.logout': 'Log out',
'common.yes': 'Yes',
'common.sure': 'Sure',
'common.no': 'No',
'common.ok': 'OK',
'common.error': 'Error',
'common.vietnamese': 'Vietnamese',
'common.english': 'English',
'common.theme.light': 'Light Theme',
'common.theme.dark': 'Dark Theme',
'common.paginations.things': 'things',
'common.paginations.of': 'of',
'common.name': 'Name',
'common.name.required': 'Name is required',
'common.type': 'Type',
'common.type.placeholder': 'Select Type',
'common.status': 'Status',
'common.province': 'Province',
'common.description': 'Description',
'common.description.required': 'Description is required',
'common.description.placeholder': 'Enter description',
'common.active': 'Active',
'common.inactive': 'Inactive',
'common.created_at': 'Created At',
'common.updated_at': 'Updated At',
'common.undefined': 'Undefined',
'common.not_empty': 'Cannot be empty!',
'common.level.normal': 'Normal',
'common.level.warning': 'Warning',
'common.level.critical': 'Critical',
'common.level.sos': 'SOS',
'common.unaccess': 'Sorry, you do not have permission to access this page',
'common.notfound': 'The page you visited does not exist',
'common.internalserver': 'Sorry, something went wrong on the server',
'common.required_field': 'This field cannot be empty',
...masterEn,
...sgwEn,
...gmsEn,
...spoleEn,
};

View File

@@ -0,0 +1,17 @@
export default {
'master.alarms.table.pagination': 'alarms',
'master.alarms.severity': 'Severity',
'master.alarms.occurred_at': 'Occurred time',
'master.alarms.confirmed': 'Confirmed',
'master.alarms.confirm.description.required': 'The description is required',
'master.alarms.confirm.title': 'Confirm Alarm',
'master.alarms.confirm.success': 'Confirm alarm successfully',
'master.alarms.confirm.fail': 'Confirm alarm failed',
'master.alarms.unconfirm.title': 'Unconfirm',
'master.alarms.unconfirm.body':
'Are you sure you want to unconfirm this alarm?',
'master.alarms.unconfirm.success': 'Unconfirm alarm successfully',
'master.alarms.unconfirm.fail': 'Unconfirm alarm failed',
'master.alarms.not_found': 'Alarm has expired or does not exist',
'master.alarms.filter_things': 'Filter by device group',
};

View File

@@ -0,0 +1,15 @@
export default {
// Authentication
'master.auth.login.title': 'Login',
'master.auth.login.email': 'Email',
'master.auth.validation.email': 'Email is required',
'master.auth.password': 'Password',
'master.auth.validation.password': 'Password is required',
'master.auth.login.subtitle': 'Ship Monitoring System',
'master.auth.login.description': 'Login to continue monitoring vessels',
'master.auth.login.invalid': 'Invalid username or password',
'master.auth.login.success': 'Login successful',
'master.auth.logout.title': 'Logout',
'master.auth.logout.confirm': 'Are you sure you want to logout?',
'master.auth.logout.success': 'Logout successful',
};

View File

@@ -0,0 +1,18 @@
import masterAlarmEn from './master-alarm-en';
import masterAuthEn from './master-auth-en';
import masterGroupEn from './master-group-en';
import masterSysLogEn from './master-log-en';
import masterMenuEn from './master-menu-en';
import masterMenuProfileEn from './master-profile-en';
import masterThingEn from './master-thing-en';
import masterUserEn from './master-user-en';
export default {
...masterAuthEn,
...masterMenuEn,
...masterMenuProfileEn,
...masterAlarmEn,
...masterThingEn,
...masterSysLogEn,
...masterUserEn,
...masterGroupEn,
};

View File

@@ -0,0 +1,21 @@
export default {
'master.groups.root': 'Add root',
'master.groups.cannot-add-group':
'Cannot add child to a group that has things',
'master.groups.add': 'Add child',
'master.groups.delete.confirm': 'Are you sure you want to delete this group?',
'master.groups.code': 'Code',
'master.groups.code.exists': 'The code already exists',
'master.groups.short_name': 'Short name',
'master.groups.short_name.exists': 'The short name already exists',
'master.groups.update.success': 'Updated group successfully',
'master.groups.update.failed': 'Update group failed, please try again!',
'master.groups.create.success': 'Created group successfully',
'master.groups.create.failed': 'Create group failed, please try again!',
'master.groups.delete.success': 'Deleted group successfully',
'master.groups.delete.failed': 'Delete group failed',
'master.groups.delete.failed_internal':
'The group contains devices or users and cannot be deleted',
'master.groups.parent': 'Parent',
'master.groups.treeselect.placeholder': 'Please select group',
};

View File

@@ -0,0 +1,51 @@
export default {
'master.logs.search.yesterday': 'Yesterday',
'master.logs.search.last_week': 'Last week',
'master.logs.search.last_month': 'Last month',
'master.logs.table.pagination': 'activities',
'master.logs.things.create': 'Create new thing',
'master.logs.things.update': 'Update thing',
'master.logs.things.remove': 'Remove thing',
'master.logs.things.share': 'Share thing',
'master.logs.things.unshare': 'Unshare thing',
'master.logs.things.update_key': 'Update key thing',
'master.logs.users.create': 'Register user',
'master.logs.users.update': 'Update user',
'master.logs.users.remove': 'Remove user',
'master.logs.users.share': 'User share',
'master.logs.groups.create': 'Create new group',
'master.logs.groups.update': 'Update group',
'master.logs.groups.remove': 'Remove group',
'master.logs.groups.assign_thing': 'Assign thing to group',
'master.logs.groups.assign_user': 'Assign user to group',
'master.logs.groups.unassign_thing': 'Remove thing from group',
'master.logs.groups.unassign_user': 'Remove user from group',
'master.logs.action.text': 'Action',
'master.logs.email.text': 'Email',
'master.logs.date.text': 'Date',
'master.logs.things.alarm.confirm': 'Alarm confirm',
'master.logs.things.alarm.unconfirm': 'Alarm unconfirm',
'master.logs.users.login': 'User login',
'master.logs.title': 'Logs',
'master.logs.things.confirm': 'Confirm',
'master.logs.things.unconfirm': 'Unconfirm',
'master.logs.things': 'Things',
'master.logs.users': 'Users',
'master.logs.groups': 'Groups',
'master.logs.ships': 'Ships',
'master.logs.ships.create': 'Create new ship',
'master.logs.ships.update': 'Update ship',
'master.logs.ships.remove': 'Remove ship',
'master.logs.ships.assign_thing': 'Assign device to ship',
'master.logs.ships.assign_user': 'Assign user to ship',
'master.logs.ships.unassign_thing': 'Remove device from ship',
'master.logs.ships.unassign_user': 'Remove user from ship',
'master.logs.trips': 'Trips',
'master.logs.trips.create': 'Create new trip',
'master.logs.trips.update': 'Update trip',
'master.logs.trips.remove': 'Remove trip',
'master.logs.trips.approve': 'Approve trip',
'master.logs.trips.request_approve': 'Request approval for trip',
'master.logs.trips.unassign_thing': 'Remove device from trip',
'master.logs.trips.unassign_user': 'Remove user from trip',
};

View File

@@ -0,0 +1,10 @@
export default {
'menu.alarms': 'Alarms',
'menu.monitoring': 'Monitoring',
'menu.manager': 'Manager',
'menu.manager.devices': 'Devices',
'menu.manager.groups': 'Groups',
'menu.manager.users': 'Users',
'menu.manager.logs': 'Logs',
'menu.profile': 'Profile',
};

View File

@@ -0,0 +1,20 @@
export default {
'master.profile.change-profile': 'Basic Information',
'master.profile.change-password': 'Change Password',
'master.profile.two-factor-authentication': 'Two-Factor Authentication',
'master.profile.change-profile.title': 'Update Personal Information',
'master.profile.change-profile.full_name': 'Full Name',
'master.profile.change-profile.phone_number': 'Phone Number',
'master.profile.change-password.old_password': 'Old Password',
'master.profile.change-password.new_password': 'New Password',
'master.profile.change-password.confirm_password': 'Confirm Password',
'master.profile.change-password.password_not_match':
'Password confirmation does not match',
'master.profile.change-profile.update-success':
'Update information successfully',
'master.profile.change-profile.update-fail': 'Update information failed',
'master.profile.change-password.success': 'Change password successfully',
'master.profile.change-password.fail': 'Change password failed',
'master.profile.change-password.password.strong':
'A password contains at least 8 characters, including at least one number and includes both lower and uppercase letters and special characters, for example #, ?, !',
};

View File

@@ -0,0 +1,6 @@
export default {
'master.thing.name': 'Name',
'master.thing.external_id': 'External ID',
'master.thing.group': 'Group',
'master.thing.address': 'Address',
};

View File

@@ -0,0 +1,35 @@
export default {
'master.users.title': 'Users',
'master.users.table.pagination': 'users',
'master.users.register': 'Register',
'master.users.register.title': 'Register user',
'master.users.email.exists': 'The email is exists',
'master.users.email': 'Email',
'master.users.email.tip': 'The email is the unique key',
'master.users.email.required': 'The email is required',
'master.users.email.invalid': 'Invalid email address',
'master.users.role': 'Role',
'master.users.role.placeholder': 'Please select a role',
'master.users.role.tip': 'The role is the unique key',
'master.users.password.required': 'Password is required',
'master.users.password.minimum': 'Minimum password length is 8',
'master.users.password': 'Password',
'master.users.full_name': 'Full name',
'master.users.full_name.placeholder': 'Enter full name',
'master.users.full_name.required': 'Please enter full name',
'master.users.password.placeholder': 'Password',
'master.users.confirmpassword.required': 'Confirm password is required',
'master.users.email.placeholder': 'Email',
'master.users.phone_number': 'Phone number',
'master.users.phone_number.tip': 'The phone number is the unique key',
'master.users.phone_number.required': 'The phone number is required',
'master.users.phone_number.notvalid': 'Invalid phone number',
'master.users.groups': 'Groups',
'master.users.groups.required': 'Please select groups',
'master.users.role.sysadmin': 'System Administrator',
'master.users.role.admin': 'Unit Manager',
'master.users.role.user': 'Unit Supervisor',
'master.users.role.sgw.end_user': 'Ship Owner',
'master.users.create.error': 'User creation failed',
'master.users.create.success': 'User created successfully',
};

View File

@@ -0,0 +1,5 @@
import gmsMenuEn from './gms-menu-en';
export default {
'gms.title': 'SmartGMS',
...gmsMenuEn,
};

View File

@@ -0,0 +1,3 @@
export default {
'menu.gms.monitor': 'Monitor',
};

View File

@@ -0,0 +1,6 @@
import sgwMenu from './sgw-menu-en';
export default {
'sgw.title': 'Sea Gateway',
'sgw.ship': 'Ship',
...sgwMenu,
};

View File

@@ -0,0 +1,6 @@
export default {
'menu.sgw.map': 'Maps',
'menu.sgw.trips': 'Trips',
'menu.manager.sgw.fishes': 'Fishes',
'menu.manager.sgw.zones': 'Zones',
};

View File

@@ -0,0 +1,5 @@
import spoleMenuEN from './spole-menu-en';
export default {
'spole.title': 'Spole',
...spoleMenuEN,
};

View File

@@ -0,0 +1,8 @@
export default {
'menu.spole.monitoring': 'Monitoring',
'menu.spole.mira': 'Mira',
'menu.spole.miva': 'Miva',
'menu.spole.traffic-light': 'Traffic Light',
'menu.spole.street-light': 'Street Light',
'menu.spole.media': 'Media',
};

64
src/locales/vi-VN.ts Normal file
View File

@@ -0,0 +1,64 @@
import masterVI from './vi-VN/master/master-vi';
import gmsVI from './vi-VN/slave/gms/gms-vi';
import sgwVI from './vi-VN/slave/sgw/sgw-vi';
import spoleVI from './vi-VN/slave/spole/spole-vi';
export default {
'app.copyright.produced': 'Sản phẩm của Mobifone.',
'common.save': 'Lưu',
'common.cancel': 'Hủy',
'common.delete': 'Xóa',
'common.delete_confirm': 'Xác nhận xóa?',
'common.deleting': 'Đang xóa...',
'common.edit': 'Chỉnh sửa',
'common.add': 'Thêm',
'common.actions': 'Hoạt động',
'common.search': 'Tìm kiếm',
'common.filter': 'Bộ lọc',
'common.loading': 'Đang tải...',
'common.updating': 'Đang cập nhật...',
'common.confirm': 'Xác nhận',
'common.notification': 'Thông báo',
'common.back': 'Quay lại',
'common.next': 'Tiếp theo',
'common.logout': 'Đăng xuất',
'common.yes': 'Có',
'common.sure': 'Chắc chắn',
'common.no': 'Không',
'common.ok': 'OK',
'common.error': 'Lỗi',
'common.vietnamese': 'Tiếng Việt',
'common.english': 'Tiếng Anh',
'common.theme.light': 'Sáng',
'common.theme.dark': 'Tối',
'common.paginations.things': 'thiết bị',
'common.paginations.of': 'trên',
'common.name': 'Tên',
'common.name.required': 'Tên không được để trống',
'common.note': 'Ghi chú',
'common.image': 'Ảnh',
'common.type': 'Loại',
'common.type.placeholder': 'Chọn loại',
'common.status': 'Trạng thái',
'common.province': 'Tỉnh',
'common.description': 'Mô tả',
'common.description.required': 'Mô tả không được để trống',
'common.description.placeholder': 'Nhập mô tả',
'common.active': 'Kích hoạt',
'common.inactive': 'Vô hiệu hóa',
'common.created_at': 'Ngày tạo',
'common.updated_at': 'Ngày cập nhật',
'common.undefined': 'Chưa xác định',
'common.not_empty': 'Không được để trống!',
'common.level.normal': 'Bình thường',
'common.level.warning': 'Cảnh báo',
'common.level.critical': 'Nguy hiểm',
'common.level.sos': 'Khẩn cấp',
'common.unaccess': 'Bạn không có quyền truy cập trang này',
'common.notfound': 'Trang bạn tìm kiếm không tồn tại',
'common.internalserver': 'Xin lỗi, đã có lỗi xảy ra ở máy chủ',
'common.required_field': 'Trường này không được để trống',
...masterVI,
...sgwVI,
...gmsVI,
...spoleVI,
};

View File

@@ -0,0 +1,16 @@
export default {
'master.alarms.table.pagination': 'cảnh báo',
'master.alarms.severity': 'Mức độ',
'master.alarms.occurred_at': 'Thời gian xảy ra',
'master.alarms.confirmed': 'Đã xác nhận',
'master.alarms.confirm.description.required': 'Mô tả không được để trống',
'master.alarms.confirm.title': 'Xác nhận cảnh báo',
'master.alarms.confirm.success': 'Xác nhận cảnh báo thành công',
'master.alarms.confirm.fail': 'Xác nhận cảnh báo thất bại',
'master.alarms.unconfirm.title': 'Ngừng xác nhận',
'master.alarms.unconfirm.body': 'Bạn chắc chắn ngừng xác nhận cảnh báo này?',
'master.alarms.unconfirm.success': 'Ngừng xác nhận cảnh báo thành công',
'master.alarms.unconfirm.fail': 'Ngừng xác nhận cảnh báo thất bại',
'master.alarms.not_found': 'Cảnh báo đã hết hạn hoặc không tồn tại',
'master.alarms.filter_things': 'Lọc theo nhóm thiết bị',
};

View File

@@ -0,0 +1,15 @@
export default {
// Authentication
'master.auth.login.email': 'Email',
'master.auth.login.title': 'Đăng nhập',
'master.auth.login.subtitle': 'Hệ thống giám sát tàu cá',
'master.auth.login.description': 'Đăng nhập để tiếp tục giám sát tàu thuyền',
'master.auth.login.invalid': 'Tên người dùng hoặc mật khẩu không hợp lệ',
'master.auth.login.success': 'Đăng nhập thành công',
'master.auth.logout.title': 'Đăng xuất',
'master.auth.logout.confirm': 'Bạn có chắc chắn muốn đăng xuất?',
'master.auth.logout.success': 'Đăng xuất thành công',
'master.auth.validation.email': 'Email không được để trống!',
'master.auth.password': 'Mật khẩu',
'master.auth.validation.password': 'Mật khẩu không được để trống!',
};

View File

@@ -0,0 +1,22 @@
export default {
'master.groups.root': 'Thêm gốc',
'master.groups.cannot-add-group':
'Không thể tạo đơn vị con khi gốc đã có thiết bị',
'master.groups.add': 'Tạo đơn vị cấp dưới',
'master.groups.delete.confirm': 'Bạn có chắc muốn xóa nhóm này không?',
'master.groups.code': 'Mã',
'master.groups.code.exists': 'Mã đã tồn tại',
'master.groups.short_name': 'Tên viết tắt',
'master.groups.short_name.exists': 'Tên viết tắt đã tồn tại',
'master.groups.update.success': 'Cập nhật đơn vị thành công',
'master.groups.update.failed': 'Cập nhật đơn vị thất bại, vui lòng thử lại!',
'master.groups.create.success': 'Tạo đơn vị thành công',
'master.groups.create.failed': 'Tạo đơn vị thất bại, vui lòng thử lại!',
'master.groups.delete.success': 'Xóa đơn vị thành công',
'master.groups.delete.failed': 'Xóa đơn vị thất bại',
'master.groups.delete.failed_internal':
'Đơn vị có chứa thiết bị hoặc người dùng, không thể xóa',
'master.groups.parent': 'Nhóm gốc',
'master.groups.treeselect.placeholder': 'Chọn khu vực',
};

View File

@@ -0,0 +1,51 @@
export default {
'master.logs.search.yesterday': 'Hôm qua',
'master.logs.search.last_week': 'Tuần trước',
'master.logs.search.last_month': 'Tháng trước',
'master.logs.table.pagination': 'hoạt động',
'master.logs.things.create': 'Tạo mới thiết bị',
'master.logs.things.update': 'Sửa thông tin thiết bị',
'master.logs.things.remove': 'Xoá thiết bị',
'master.logs.things.share': 'Chia sẻ thiết bị',
'master.logs.things.unshare': 'Ngừng chia sẻ thiết bị',
'master.logs.things.update_key': 'Cập nhật mã khoá thiết bị',
'master.logs.users.create': 'Đăng kí người dùng mới',
'master.logs.users.update': 'Cập nhật thông tin người dùng',
'master.logs.users.remove': 'Xoá người dùng',
'master.logs.users.share': 'Đăng nhập',
'master.logs.groups.create': 'Tạo mới đơn vị',
'master.logs.groups.update': 'Sửa thông tin đơn vị',
'master.logs.groups.remove': 'Xoá đơn vị',
'master.logs.groups.assign_thing': 'Thêm thiết bị vào đơn vị',
'master.logs.groups.assign_user': 'Thêm người dùng vào đơn vị',
'master.logs.groups.unassign_thing': 'Xoá thiết bị khỏi đơn vị',
'master.logs.groups.unassign_user': 'Xoá người dùng khỏi đơn vị',
'master.logs.action.text': 'Thao tác',
'master.logs.email.text': 'Người dùng',
'master.logs.date.text': 'Ngày giờ',
'master.logs.things.alarm.confirm': 'Xác nhận cảnh báo',
'master.logs.things.alarm.unconfirm': 'Ngừng xác nhận cảnh báo',
'master.logs.users.login': 'Đăng nhập',
'master.logs.title': 'Nhật ký',
'master.logs.things.confirm': 'Xác nhận',
'master.logs.things.unconfirm': 'Ngừng xác nhận',
'master.logs.things': 'Thiết bị',
'master.logs.users': 'Người dùng',
'master.logs.groups': 'Đơn vị',
'master.logs.ships': 'Tàu',
'master.logs.ships.create': 'Tạo mới tàu',
'master.logs.ships.update': 'Sửa thông tin tàu',
'master.logs.ships.remove': 'Xoá tàu',
'master.logs.ships.assign_thing': 'Thêm thiết bị vào tàu',
'master.logs.ships.assign_user': 'Thêm người dùng vào tàu',
'master.logs.ships.unassign_thing': 'Xoá thiết bị khỏi tàu',
'master.logs.ships.unassign_user': 'Xoá người dùng khỏi tàu',
'master.logs.trips': 'Chuyến đi',
'master.logs.trips.create': 'Tạo mới chuyến đi',
'master.logs.trips.update': 'Sửa thông tin chuyến đi',
'master.logs.trips.remove': 'Xoá chuyến đi',
'master.logs.trips.approve': 'Duyệt chuyến đi',
'master.logs.trips.request_approve': 'Yêu cầu xác nhận chuyến đi',
'master.logs.trips.unassign_thing': 'Xoá thiết bị khỏi chuyến đi',
'master.logs.trips.unassign_user': 'Xoá người dùng khỏi chuyến đi',
};

View File

@@ -0,0 +1,10 @@
export default {
'menu.monitoring': 'Giám sát',
'menu.alarms': 'Cảnh báo',
'menu.manager': 'Quản lý',
'menu.manager.devices': 'Thiết bị',
'menu.manager.groups': 'Đơn vị',
'menu.manager.users': 'Người dùng',
'menu.manager.logs': 'Hoạt động',
'menu.profile': 'Thông tin cá nhân',
};

View File

@@ -0,0 +1,20 @@
export default {
'master.profile.change-profile': 'Thông tin cơ bản',
'master.profile.change-password': 'Đổi mật khẩu',
'master.profile.two-factor-authentication': 'Xác thực 2 bước',
'master.profile.change-profile.title': 'Cập nhật thông tin cá nhân',
'master.profile.change-profile.full_name': 'Họ và tên',
'master.profile.change-profile.phone_number': 'Số điện thoại',
'master.profile.change-password.old_password': 'Mật khẩu cũ',
'master.profile.change-password.new_password': 'Mật khẩu mới',
'master.profile.change-password.password.strong':
'Mật khẩu phải chứa ít nhất 8 ký tự, bao gồm ít nhất một số và bao gồm cả chữ thường, chữ hoa và ký tự đặc biệt, ví dụ #, ?, !',
'master.profile.change-password.confirm_password': 'Xác nhận mật khẩu',
'master.profile.change-password.password_not_match':
'Mật khẩu xác nhận không khớp',
'master.profile.change-profile.update-success':
'Cập nhật thông tin thành công',
'master.profile.change-profile.update-fail': 'Cập nhật thông tin thất bại',
'master.profile.change-password.success': 'Đổi mật khẩu thành công',
'master.profile.change-password.fail': 'Đổi mật khẩu thất bại',
};

View File

@@ -0,0 +1,6 @@
export default {
'master.thing.name': 'Tên',
'master.thing.external_id': 'External ID',
'master.thing.group': 'Nhóm',
'master.thing.address': 'Địa chỉ',
};

View File

@@ -0,0 +1,35 @@
export default {
'master.users.title': 'Quản lý người dùng',
'master.users.table.pagination': 'người dùng',
'master.users.register': 'Thêm người dùng',
'master.users.register.title': 'Đăng kí người dùng mới',
'master.users.email': 'Email',
'master.users.email.tip': 'Email là duy nhất',
'master.users.email.required': 'Vui lòng nhập email',
'master.users.email.invalid': 'Email không hợp lệ',
'master.users.email.exists': 'Email đã sử dụng',
'master.users.role': 'Vai trò',
'master.users.role.placeholder': 'Vui lòng chọn vai trò',
'master.users.role.tip': 'Vai trò là duy nhất',
'master.users.password.required': 'Vui lòng nhập mật khẩu',
'master.users.password.minimum': 'Mật khẩu ít nhất 8 kí tự',
'master.users.password': 'Mật khẩu',
'master.users.password.placeholder': 'Nhập mật khẩu',
'master.users.confirmpassword.required': 'Vui lòng nhập lại mật khẩu',
'master.users.full_name': 'Tên đầy đủ',
'master.users.full_name.placeholder': 'Nhập tên đầy đủ',
'master.users.full_name.required': 'Vui lòng nhập tên đầy đủ',
'master.users.email.placeholder': 'Email',
'master.users.phone_number': 'Số điện thoại',
'master.users.phone_number.tip': 'Số điện thoại là duy nhất',
'master.users.phone_number.required': 'Vui lòng nhập số điện thoại',
'master.users.phone_number.notvalid': 'Số điện thoại không hợp lệ',
'master.users.groups': 'Đơn vị',
'master.users.groups.required': 'Vui lòng chọn đơn vị',
'master.users.role.sysadmin': 'Quản lý hệ thống',
'master.users.role.admin': 'Quản lý đơn vị',
'master.users.role.user': 'Giám sát đơn vị',
'master.users.role.sgw.end_user': 'Chủ tàu',
'master.users.create.error': 'Tạo người dùng lỗi',
'master.users.create.success': 'Tạo người dùng thành công',
};

View File

@@ -0,0 +1,18 @@
import masterAlarmVi from './master-alarm-vi';
import masterAuthVi from './master-auth-vi';
import masterGroupVi from './master-group-vi';
import masterSysLogVi from './master-log-vi';
import masterMenuVi from './master-menu-vi';
import masterProfileVi from './master-profile-vi';
import masterThingVi from './master-thing-vi';
import masterUserVi from './master-user-vi';
export default {
...masterAuthVi,
...masterMenuVi,
...masterProfileVi,
...masterAlarmVi,
...masterThingVi,
...masterSysLogVi,
...masterUserVi,
...masterGroupVi,
};

View File

@@ -0,0 +1,3 @@
export default {
'menu.gms.monitor': 'Giám sát',
};

View File

@@ -0,0 +1,5 @@
import gmsMenuVi from './gms-menu-vi';
export default {
'gms.title': 'Quản lý giám sát thông minh',
...gmsMenuVi,
};

View File

@@ -0,0 +1,6 @@
export default {
'menu.sgw.map': 'Bản đồ',
'menu.sgw.trips': 'Chuyến đi',
'menu.manager.sgw.fishes': 'Loài cá',
'menu.manager.sgw.zones': 'Khu vực',
};

View File

@@ -0,0 +1,6 @@
import sgwMenu from './sgw-menu-vi';
export default {
'sgw.title': 'Hệ thống giám sát tàu cá',
'sgw.ship': 'Tàu',
...sgwMenu,
};

View File

@@ -0,0 +1,8 @@
export default {
'menu.spole.monitoring': 'Giám sát',
'menu.spole.mira': 'Mira',
'menu.spole.miva': 'Miva',
'menu.spole.traffic-light': 'Đèn giao thông',
'menu.spole.street-light': 'Đèn',
'menu.spole.media': 'Media',
};

View File

@@ -0,0 +1,5 @@
import spoleMenuVI from './spole-menu-vi';
export default {
'spole.title': 'Quản lý giám sát thông minh',
...spoleMenuVI,
};

View File

@@ -0,0 +1,90 @@
import { InitialStateResponse } from '@/app';
import { LIMIT_TREE_LEVEL } from '@/constants';
import { apiQueryGroups } from '@/services/master/GroupController';
import { useModel } from '@umijs/max';
import { useCallback, useEffect, useRef, useState } from 'react';
type UseGetGroupsResult = {
groups: MasterModel.GroupNode[] | null;
groupMap: GroupMap;
loading: boolean;
getGroups: () => Promise<void>;
};
type GroupMap = Record<string, { name: string; code?: string }>;
function buildGroupMap(
groups: MasterModel.GroupNode[],
map: GroupMap = {},
): GroupMap {
groups.forEach((g) => {
map[g.id] = {
name: g.name,
code: g.metadata?.code,
};
if (g.children?.length) {
buildGroupMap(g.children, map);
}
});
return map;
}
export default function useGetGroups(): UseGetGroupsResult {
const [groups, setGroups] = useState<MasterModel.GroupNode[] | null>(null);
const [groupMap, setGroupMap] = useState<GroupMap>({});
const [loading, setLoading] = useState(false);
const { initialState } = useModel('@@initialState') as {
initialState?: InitialStateResponse;
};
const currentUserProfile = initialState?.currentUserProfile;
/**
* ✅ FIX CACHE
* Khi user đổi (logout / login user khác)
* → clear groups cũ
*/
const prevUserIdRef = useRef<string | null>(null);
useEffect(() => {
const currentUserId = currentUserProfile?.id ?? null;
if (prevUserIdRef.current && prevUserIdRef.current !== currentUserId) {
// user đổi → clear cache
setGroups(null);
}
prevUserIdRef.current = currentUserId;
}, [currentUserProfile?.id]);
/**
* ✅ GIỮ NGUYÊN LOGIC CŨ
* chỉ thêm guard + dependency đúng
**/
const getGroups = useCallback(async (): Promise<void> => {
if (!currentUserProfile?.id) return;
setLoading(true);
try {
const tree = await apiQueryGroups({
level: LIMIT_TREE_LEVEL,
tree: true,
});
setGroups(tree?.groups || []);
setGroupMap(buildGroupMap(tree?.groups || []));
} catch (err) {
console.error('Fetch data failed', err);
setGroups([]);
} finally {
setLoading(false);
}
}, [currentUserProfile]);
return {
groups,
groupMap,
loading,
getGroups,
};
}

View File

@@ -0,0 +1,44 @@
import {
CheckCircleOutlined,
CommentOutlined,
UserOutlined,
} from '@ant-design/icons';
import { Space, Typography } from 'antd';
import moment from 'moment';
const { Text } = Typography;
interface AlarmDescriptionProps {
alarm: MasterModel.Alarm;
}
const AlarmDescription = ({ alarm }: AlarmDescriptionProps) => {
if (!alarm?.confirmed) {
return null;
}
return (
<Space size="large" wrap>
<Space align="baseline" size={10}>
<CheckCircleOutlined style={{ color: '#52c41a' }} />
<Text type="secondary" style={{ fontSize: 15 }}>
{alarm.confirmed_time
? moment.unix(alarm.confirmed_time).format('YYYY-MM-DD HH:mm:ss')
: '-'}
</Text>
</Space>
<Space size={10}>
<UserOutlined style={{ color: '#1890ff' }} />
<Text style={{ fontSize: 15 }}>{alarm.confirmed_email}</Text>
</Space>
{alarm.confirmed_desc && (
<Space size={10}>
<CommentOutlined style={{ color: '#faad14' }} />
<Text style={{ fontSize: 15 }}>{alarm.confirmed_desc}</Text>
</Space>
)}
</Space>
);
};
export default AlarmDescription;

View File

@@ -0,0 +1,152 @@
import { HTTPSTATUS } from '@/constants';
import { apiConfirmAlarm } from '@/services/master/AlarmController';
import { CheckOutlined } from '@ant-design/icons';
import {
ModalForm,
ProForm,
ProFormInstance,
ProFormText,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, Flex } from 'antd';
import { MessageInstance } from 'antd/es/message/interface';
import moment from 'moment';
import { useRef, useState } from 'react';
type AlarmForm = {
name: string;
time: string;
description: string;
};
type AlarmFormConfirmProps = {
alarm: MasterModel.Alarm;
message: MessageInstance;
onFinish?: (reload: boolean) => void;
};
const AlarmFormConfirm = ({
alarm,
message,
onFinish,
}: AlarmFormConfirmProps) => {
const [modalVisit, setModalVisit] = useState(false);
const formRef = useRef<ProFormInstance<AlarmForm>>();
const intl = useIntl();
return (
<ModalForm<AlarmForm>
open={modalVisit}
formRef={formRef}
title={
<Flex align="center" justify="center">
<FormattedMessage id="alarms.confirm.title" />
</Flex>
}
width="40%"
layout="horizontal"
modalProps={{
destroyOnHidden: true,
}}
onOpenChange={setModalVisit}
request={async () => {
return {
name: alarm.name ?? '',
time: moment.unix(alarm.time!).format('DD/MM/YYYY HH:mm:ss'),
description: alarm.confirmed_desc ?? '',
};
}}
onFinish={async (values) => {
const body: MasterModel.ConfirmAlarmRequest = {
id: alarm.id,
description: values.description,
thing_id: alarm.thing_id,
time: alarm.time,
};
try {
const resp = await apiConfirmAlarm(body);
if (resp.status === HTTPSTATUS.HTTP_ACCEPTED) {
message.success({
content: intl.formatMessage({
id: 'alarms.confirm.success',
defaultMessage: 'Confirm alarm successfully',
}),
});
onFinish?.(true);
} else if (resp.status === HTTPSTATUS.HTTP_NOTFOUND) {
message.warning({
content: intl.formatMessage({
id: 'alarms.not_found',
defaultMessage: 'Alarm has expired or does not exist',
}),
});
onFinish?.(true);
} else {
throw new Error('Failed to confirm alarm');
}
} catch (error) {
console.error('Error when confirm alarm: ', error);
message.error({
content: intl.formatMessage({
id: 'alarms.confirm.fail',
defaultMessage: 'Confirm alarm failed',
}),
});
}
return true;
}}
trigger={
<Button
size="small"
variant="solid"
color="green"
icon={<CheckOutlined />}
onClick={() => setModalVisit(true)}
>
{intl.formatMessage({
id: 'common.confirm',
})}
</Button>
}
>
<ProForm.Group>
<ProFormText
name="name"
label={intl.formatMessage({
id: 'common.name',
defaultMessage: 'Name',
})}
readonly={true}
/>
<ProFormText
name="time"
label={intl.formatMessage({
id: 'alarms.occurred_at',
defaultMessage: 'When',
})}
readonly={true}
/>
</ProForm.Group>
<ProFormText
fieldProps={{
autoFocus: true,
}}
name="description"
label={intl.formatMessage({
id: 'common.description',
defaultMessage: 'Description',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'alarms.confirm.description.required',
defaultMessage: 'The description is required',
}),
},
]}
/>
</ModalForm>
);
};
export default AlarmFormConfirm;

332
src/pages/Alarm/index.tsx Normal file
View File

@@ -0,0 +1,332 @@
import ThingsFilter from '@/components/shared/ThingFilterModal';
import { DATE_TIME_FORMAT, DEFAULT_PAGE_SIZE, HTTPSTATUS } from '@/constants';
import {
apiGetAlarms,
apiUnconfirmAlarm,
} from '@/services/master/AlarmController';
import {
CloseOutlined,
DeleteOutlined,
FilterOutlined,
} from '@ant-design/icons';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, Flex, message, Popconfirm, Progress, Tooltip } from 'antd';
import moment from 'moment';
import { useRef, useState } from 'react';
import AlarmDescription from './components/AlarmDescription';
import AlarmFormConfirm from './components/AlarmFormConfirm';
const AlarmPage = () => {
const tableRef = useRef<ActionType>();
const [selectedKey, setSelectedKey] = useState<React.Key>();
const [thingFilterVisible, setThingFilterVisible] = useState<boolean>(false);
const [thingFilterDatas, setThingFilterDatas] = useState<string[]>([]);
const intl = useIntl();
const [messageApi, contextHolder] = message.useMessage();
const columns: ProColumns<MasterModel.Alarm>[] = [
{
title: intl.formatMessage({
id: 'common.name',
defaultMessage: 'Name',
}),
dataIndex: 'name',
key: 'name',
copyable: true,
},
{
title: intl.formatMessage({
id: 'master.alarms.severity',
defaultMessage: 'Severity',
}),
dataIndex: 'level',
key: 'level',
responsive: ['lg', 'md', 'sm'],
filters: true,
onFilter: true,
valueType: 'select',
valueEnum: {
0: {
text: intl.formatMessage({
id: 'common.level.normal',
defaultMessage: 'Normal',
}),
status: 'Success',
},
1: {
text: intl.formatMessage({
id: 'common.level.warning',
defaultMessage: 'Warning',
}),
status: 'Warning',
},
2: {
text: intl.formatMessage({
id: 'common.level.critical',
defaultMessage: 'Critical',
}),
status: 'Error',
},
3: {
text: intl.formatMessage({
id: 'common.level.sos',
defaultMessage: 'SOS',
}),
status: 'Error',
},
},
},
{
title: intl.formatMessage({
id: 'sgw.ship',
defaultMessage: 'Source',
}),
dataIndex: 'thing_name',
key: 'thing_name',
responsive: ['lg', 'md', 'sm'],
ellipsis: true,
copyable: true,
// render: (dom, row) => {
// return (
// <Paragraph copyable>
// <Link
// onClick={() => {
// const t = row?.thing_type;
// const path = `/devices/${row?.thing_id}/${t}`;
// history.push({
// pathname: path,
// });
// }}
// >
// {dom}
// </Link>
// </Paragraph>
// );
// },
// render: (_, item) => {
// return <Paragraph copyable>{item?.thing_name}</Paragraph>
// }
},
{
title: intl.formatMessage({
id: 'master.alarms.occurred_at',
defaultMessage: 'Occured time',
}),
dataIndex: 'time',
key: 'time',
search: false,
render: (_, item) => {
return moment.unix(item.time!).format(DATE_TIME_FORMAT);
},
},
{
title: intl.formatMessage({
id: 'master.alarms.confirmed',
defaultMessage: 'Confirmed',
}),
dataIndex: 'confirmed',
responsive: ['lg', 'md'],
align: 'center',
width: '10%',
search: false,
render: (_, item) => {
return item?.confirmed ? (
<Progress type="circle" percent={100} size={24} />
) : null;
},
},
];
return (
<>
<ThingsFilter
isOpen={thingFilterVisible}
setIsOpen={setThingFilterVisible}
thingIds={thingFilterDatas}
onSubmit={(thingIds) => {
console.log('ThingIDs: ', thingIds);
setThingFilterDatas(thingIds);
tableRef.current?.reload();
}}
/>
{contextHolder}
<ProTable<MasterModel.Alarm>
actionRef={tableRef}
rowKey={(record) => `${record.thing_id}_${record.id}`}
columns={columns}
rowSelection={{
type: 'checkbox',
selectedRowKeys: selectedKey ? [selectedKey] : [],
onChange: (selectedKeys) => {
// Checkbox with max 1 row selection - toggle behavior
if (selectedKeys.length === 0) {
setSelectedKey(undefined);
} else {
// Get the key that's different from current selection (the newly clicked row)
const newKey = selectedKeys.find((key) => key !== selectedKey);
setSelectedKey(newKey ?? selectedKeys[0]);
}
},
}}
size="large"
tableAlertRender={({ selectedRows }) => {
const alarm = selectedRows[0];
return <AlarmDescription alarm={alarm} />;
}}
options={{
search: false,
setting: false,
density: false,
reload: true,
}}
toolbar={{
actions: [
<Tooltip
title={intl.formatMessage({ id: 'master.alarms.filter_things' })}
key="thing-filter-tooltip"
>
<Button
color={thingFilterDatas.length > 0 ? 'red' : 'default'}
icon={<FilterOutlined />}
variant="text"
onClick={() => setThingFilterVisible(true)}
/>
</Tooltip>,
],
}}
tableAlertOptionRender={({ selectedRows, onCleanSelected }) => {
const alarm = selectedRows[0];
return (
<Flex gap={10}>
{alarm?.confirmed || false ? (
<Popconfirm
title={intl.formatMessage({
id: 'master.alarms.unconfirm.body',
defaultMessage:
'Are you sure you want to unconfirm this alarm?',
})}
okText={intl.formatMessage({
id: 'common.yes',
defaultMessage: 'Yes',
})}
cancelText={intl.formatMessage({
id: 'common.no',
defaultMessage: 'No',
})}
onConfirm={async () => {
const body: MasterModel.ConfirmAlarmRequest = {
id: alarm.id,
thing_id: alarm.thing_id,
time: alarm.time,
};
try {
const resp = await apiUnconfirmAlarm(body);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success({
content: intl.formatMessage({
id: 'master.alarms.unconfirm.success',
defaultMessage: 'Confirm alarm successfully',
}),
});
tableRef.current?.reload();
} else if (resp.status === HTTPSTATUS.HTTP_NOTFOUND) {
message.warning({
content: intl.formatMessage({
id: 'master.alarms.not_found',
defaultMessage:
'Alarm has expired or does not exist',
}),
});
tableRef.current?.reload();
} else {
throw new Error('Failed to confirm alarm');
}
} catch (error) {
console.error('Error when unconfirm alarm: ', error);
message.error({
content: intl.formatMessage({
id: 'master.alarms.unconfirm.fail',
defaultMessage: 'Unconfirm alarm failed',
}),
});
}
}}
>
<Button danger icon={<DeleteOutlined />} size="small">
<FormattedMessage id="master.alarms.unconfirm.title" />
</Button>
</Popconfirm>
) : (
<AlarmFormConfirm
alarm={alarm}
message={messageApi}
onFinish={(isReload) => {
if (isReload && tableRef.current) {
tableRef.current.reload();
}
}}
/>
)}
<Button
size="small"
icon={<CloseOutlined />}
onClick={onCleanSelected}
danger
/>
</Flex>
);
}}
pagination={{
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
showSizeChanger: true,
pageSizeOptions: ['5', '10', '15', '20'],
showTotal: (total, range) =>
`${range[0]}-${range[1]}
${intl.formatMessage({
id: 'common.paginations.of',
defaultMessage: 'of',
})}
${total} ${intl.formatMessage({
id: 'master.alarms.table.pagination',
defaultMessage: 'alarms',
})}`,
}}
request={async (params) => {
const { current, pageSize, name, level, thing_name } = params;
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
const body: MasterModel.SearchAlarmPaginationBody = {
name: name,
order: 'name',
limit: pageSize,
offset: offset,
level: level as number | undefined,
thing_name: thing_name,
dir: 'desc',
};
if (thingFilterDatas.length > 0) {
body.thing_id = Array.isArray(thingFilterDatas)
? thingFilterDatas.join(',')
: thingFilterDatas;
}
try {
const res = await apiGetAlarms(body);
return {
data: res.alarms,
total: res.total,
success: true,
};
} catch (error) {
return {
data: [],
total: 0,
success: false,
};
}
}}
></ProTable>
</>
);
};
export default AlarmPage;

212
src/pages/Auth/index.tsx Normal file
View File

@@ -0,0 +1,212 @@
import Footer from '@/components/Footer';
import LangSwitches from '@/components/Lang/LanguageSwitcherAuth';
import ThemeSwitcherAuth from '@/components/Theme/ThemeSwitcherAuth';
import { ROUTER_HOME } from '@/constants/routes';
import { apiLogin, apiQueryProfile } from '@/services/master/AuthController';
import { parseJwt } from '@/utils/jwt';
import { getLogoImage } from '@/utils/logo';
import { getBrowserId, getToken, removeToken, setToken } from '@/utils/storage';
import { LockOutlined, UserOutlined } from '@ant-design/icons';
import { LoginFormPage, ProFormText } from '@ant-design/pro-components';
import { history, useIntl, useModel } from '@umijs/max';
import { Image, theme } from 'antd';
import { useEffect } from 'react';
import { flushSync } from 'react-dom';
import mobifontLogo from '../../../public/mobifont-logo.png';
const LoginPage = () => {
const { token } = theme.useToken();
const urlParams = new URL(window.location.href).searchParams;
const redirect = urlParams.get('redirect');
const intl = useIntl();
const { setInitialState } = useModel('@@initialState');
const getDomainTitle = () => {
switch (process.env.DOMAIN_ENV) {
case 'gms':
return 'gms.title';
case 'sgw':
return 'sgw.title';
case 'spole':
return 'spole.title';
default:
return 'Smatec Master';
}
};
const checkLogin = async () => {
const token = getToken();
if (!token) {
return;
}
const parsed = parseJwt(token);
const { exp } = parsed;
const now = Math.floor(Date.now() / 1000);
const oneHour = 60 * 60;
if (exp - now < oneHour) {
removeToken();
} else {
const userInfo = await apiQueryProfile();
if (userInfo) {
flushSync(() => {
setInitialState((s: any) => ({
...s,
currentUserProfile: userInfo,
}));
});
}
if (redirect) {
history.push(redirect);
} else {
history.push(ROUTER_HOME);
}
}
};
useEffect(() => {
checkLogin();
}, []);
const handleLogin = async (values: MasterModel.LoginRequestBody) => {
try {
const { email, password } = values;
const resp = await apiLogin({
guid: getBrowserId(),
email,
password,
});
if (resp?.token) {
setToken(resp.token);
const userInfo = await apiQueryProfile();
if (userInfo) {
flushSync(() => {
setInitialState((s: any) => ({
...s,
currentUserProfile: userInfo,
}));
});
}
if (redirect) {
history.push(redirect);
} else {
history.push(ROUTER_HOME);
}
}
} catch (error) {
console.error('Login error:', error);
}
};
return (
<div
style={{
backgroundColor: 'white',
height: '100vh',
}}
>
<LoginFormPage
backgroundImageUrl="https://mdn.alipayobjects.com/huamei_gcee1x/afts/img/A*y0ZTS6WLwvgAAAAAAAAAAAAADml6AQ/fmt.webp"
logo={getLogoImage()}
backgroundVideoUrl="https://gw.alipayobjects.com/v/huamei_gcee1x/afts/video/jXRBRK_VAwoAAAAAAAAAAAAAK4eUAQBr"
title={
<span style={{ color: token.colorBgContainer }}>
{intl.formatMessage({
id: getDomainTitle(),
defaultMessage: 'Smatec',
})}
</span>
}
containerStyle={{
backgroundColor: 'rgba(0, 0, 0,0.65)',
backdropFilter: 'blur(4px)',
}}
subTitle={<Image preview={false} src={mobifontLogo} />}
submitter={{
searchConfig: {
submitText: intl.formatMessage({
id: 'master.auth.login.title',
defaultMessage: 'Đăng nhập',
}),
},
}}
onFinish={async (values: MasterModel.LoginRequestBody) =>
handleLogin(values)
}
>
<>
<ProFormText
name="email"
fieldProps={{
autoComplete: 'email',
autoFocus: true,
size: 'large',
prefix: (
<UserOutlined
style={{
color: token.colorText,
}}
className={'prefixIcon'}
/>
),
}}
placeholder={intl.formatMessage({
id: 'master.auth.login.email',
defaultMessage: 'Email',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.auth.validation.email',
defaultMessage: 'Email không được để trống!',
}),
},
]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
autoComplete: 'current-password',
prefix: (
<LockOutlined
style={{
color: token.colorText,
}}
className={'prefixIcon'}
/>
),
}}
placeholder={intl.formatMessage({
id: 'master.auth.password',
defaultMessage: 'Mật khẩu',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.auth.validation.password',
defaultMessage: 'Mật khẩu không được để trống!',
}),
},
]}
/>
</>
</LoginFormPage>
<div className="absolute top-5 right-5 z-50 flex gap-4">
<ThemeSwitcherAuth />
<LangSwitches />
</div>
<div
style={{
backgroundColor: 'transparent',
position: 'absolute',
bottom: 0,
zIndex: 99,
width: '100%',
}}
>
<Footer />
</div>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,22 @@
import { ROUTER_HOME } from '@/constants/routes';
import { history, useIntl } from '@umijs/max';
import { Button, Result } from 'antd';
import React from 'react';
const InternalServerErrorPage: React.FC = () => {
const intl = useIntl();
return (
<Result
status="500"
title="500"
subTitle={intl.formatMessage({ id: 'common.internalserver' })}
extra={
<Button onClick={() => history.push(ROUTER_HOME)} type="primary">
{intl.formatMessage({ id: 'common.back' })}
</Button>
}
/>
);
};
export default InternalServerErrorPage;

View File

@@ -0,0 +1,22 @@
import { ROUTER_HOME } from '@/constants/routes';
import { history, useIntl } from '@umijs/max';
import { Button, Result } from 'antd';
import React from 'react';
const NotFoundPage: React.FC = () => {
const intl = useIntl();
return (
<Result
status="404"
title="404"
subTitle={intl.formatMessage({ id: 'common.notfound' })}
extra={
<Button onClick={() => history.push(ROUTER_HOME)} type="primary">
{intl.formatMessage({ id: 'common.back' })}
</Button>
}
/>
);
};
export default NotFoundPage;

View File

@@ -0,0 +1,26 @@
import { ROUTER_HOME } from '@/constants/routes';
import { history, useIntl } from '@umijs/max';
import { Button, Result } from 'antd';
const UnAccessPage = () => {
const intl = useIntl();
return (
<Result
status="403"
title="403"
subTitle={intl.formatMessage({ id: 'common.unaccess' })}
extra={
<Button
type="primary"
onClick={() => {
history.push(ROUTER_HOME);
}}
>
{intl.formatMessage({ id: 'common.back' })}
</Button>
}
/>
);
};
export default UnAccessPage;

View File

@@ -0,0 +1,5 @@
const ManagerDevicePage = () => {
return <div>ManagerDevicePage</div>;
};
export default ManagerDevicePage;

View File

@@ -0,0 +1,300 @@
import {
apiCreateGroup,
apiUpdateGroup,
} from '@/services/master/GroupController';
import {
ModalForm,
ProFormInstance,
ProFormText,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
import { MessageInstance } from 'antd/es/message/interface';
import { useEffect, useRef } from 'react';
type CreateOrUpdateGroupProps = {
type: 'create-root' | 'create-child' | 'update';
isOpen?: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
group?: MasterModel.GroupNode | null;
message: MessageInstance;
};
type HandleGroupForm = {
parent_id?: string;
parent_name?: string;
name: string;
code: string;
short_name: string;
description?: string;
};
const CreateOrUpdateGroup = ({
type,
isOpen,
setIsOpen,
group,
message,
}: CreateOrUpdateGroupProps) => {
const formRef = useRef<ProFormInstance<HandleGroupForm>>();
const intl = useIntl();
const { groups, getGroups } = useModel('master.useGroups');
// Sync form values when modal opens or group/type changes
useEffect(() => {
console.log(
'useEffect trigger - isOpen:',
isOpen,
'group:',
group,
'type:',
type,
);
if (isOpen && formRef.current) {
if (type === 'update' && group) {
formRef.current.setFieldsValue({
parent_id: group?.id || '',
parent_name: '',
name: group?.name || '',
code: group?.metadata?.code || '',
short_name: group?.metadata?.short_name || '',
description: group?.description || '',
});
} else if (type === 'create-child' && group) {
formRef.current.setFieldsValue({
parent_id: group?.id,
parent_name: group?.name || '',
name: '',
code: '',
short_name: '',
description: '',
});
} else if (type === 'create-root') {
formRef.current.setFieldsValue({
parent_id: '',
parent_name: '',
name: '',
code: '',
short_name: '',
description: '',
});
}
}
}, [isOpen, group, type]);
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
};
return (
<ModalForm<HandleGroupForm>
open={isOpen}
onOpenChange={handleOpenChange}
layout="vertical"
formRef={formRef}
title={
group === undefined ? (
<FormattedMessage id="master.groups.root" />
) : (
<FormattedMessage id="master.groups.add" />
)
}
onFinish={async (values) => {
const body: Partial<MasterModel.GroupBodyRequest> = {
name: values.name,
description: values.description,
metadata: {
code: values.code,
short_name: values.short_name,
},
};
switch (type) {
case 'update': {
body.id = group?.id;
try {
const response = await apiUpdateGroup(body);
if (response.id) {
message?.success(
intl.formatMessage({
id: 'master.groups.update.success',
defaultMessage: 'Updated successfully',
}),
);
getGroups();
return true;
}
} catch (error) {
console.log('Error when update group: ', error);
message?.error(
intl.formatMessage({
id: 'master.groups.update.failed',
defaultMessage: 'Update failed, please try again!',
}),
);
return false;
}
break;
}
case 'create-child': {
body.parent_id = group?.id;
try {
const response = await apiCreateGroup(body);
if (response.id) {
message?.success(
intl.formatMessage({
id: 'master.groups.create.success',
defaultMessage: 'Created group successfully',
}),
);
setIsOpen(false);
getGroups();
return true;
}
} catch (error) {
console.log('Error when create group: ', error);
message?.error(
intl.formatMessage({
id: 'master.groups.create.failed',
defaultMessage: 'Create group failed, please try again!',
}),
);
return false;
}
break;
}
case 'create-root': {
try {
const response = await apiCreateGroup(body);
if (response.id) {
message?.success(
intl.formatMessage({
id: 'master.groups.create.success',
defaultMessage: 'Created group successfully',
}),
);
setIsOpen(false);
getGroups();
return true;
} else {
throw new Error('Create group failed');
}
} catch (error) {
console.log('Error when create group: ', error);
message?.error(
intl.formatMessage({
id: 'master.groups.create.failed',
defaultMessage: 'Create group failed, please try again!',
}),
);
return false;
}
}
}
}}
>
<ProFormText name="parent_id" hidden />
{type !== 'update' && (
<ProFormText
name="parent_name"
disabled
label={intl.formatMessage({
id: 'master.groups.parent',
defaultMessage: 'Parent',
})}
/>
)}
<ProFormText
name="name"
label={intl.formatMessage({
id: 'common.name',
defaultMessage: 'Name',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="common.name.required"
defaultMessage="The name is required"
/>
),
},
]}
/>
<ProFormText
name="code"
label={intl.formatMessage({
id: 'master.groups.code',
defaultMessage: 'Code',
})}
rules={[
{ required: true },
{
validator: async (_, value) => {
if (
value &&
groups!.some(
(g) => g?.metadata?.code === value && type !== 'update',
)
) {
throw new Error(
intl.formatMessage({
id: 'master.groups.code.exists',
defaultMessage: 'The code already exists',
}),
);
}
},
},
]}
/>
<ProFormText
name="short_name"
label={intl.formatMessage({
id: 'master.groups.short_name',
defaultMessage: 'Short name',
})}
rules={[
{ required: true },
{
validator: async (_, value) => {
if (
value &&
groups!.some(
(g) => g?.metadata?.short_name === value && type !== 'update',
)
) {
throw new Error(
intl.formatMessage({
id: 'master.groups.short_name.exists',
defaultMessage: 'The short name already exists',
}),
);
}
},
},
]}
/>
<ProFormText
name="description"
label={intl.formatMessage({
id: 'common.description',
defaultMessage: 'Description',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="common.description.required"
defaultMessage="The description is required"
/>
),
},
]}
/>
</ModalForm>
);
};
export default CreateOrUpdateGroup;

View File

@@ -0,0 +1,451 @@
import IconFont from '@/components/IconFont';
import TreeGroup from '@/components/shared/TreeGroup';
import { HTTPSTATUS } from '@/constants';
import {
apiDeleteGroup,
apiUpdateGroup,
} from '@/services/master/GroupController';
import {
DeleteOutlined,
EditOutlined,
ExclamationCircleOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { ProCard, ProDescriptions } from '@ant-design/pro-components';
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
import { Button, Grid, message, Modal } from 'antd';
import { Dropdown, MenuProps, Tooltip } from 'antd/lib';
import { useState } from 'react';
import CreateOrUpdateGroup from './components/CreateOrUpdateGroup';
type MenuItem = Required<MenuProps>['items'][number];
const ManagerGroupPage = () => {
const { useBreakpoint } = Grid;
const { initialState } = useModel('@@initialState');
const { currentUserProfile } = initialState || {};
const intl = useIntl();
const screens = useBreakpoint();
const [selectedItem, setSelectedItem] = useState<
MasterModel.GroupNode | undefined
>();
const [selectedGroup, setSelectedGroup] = useState<
MasterModel.GroupNode | undefined
>();
const { groups, getGroups } = useModel('master.useGroups');
const [handleChildModal, setHandleChildModal] = useState<boolean>(false);
const [type, setType] = useState<'create-root' | 'create-child' | 'update'>(
'create-root',
);
const [messageApi, contextHolder] = message.useMessage();
const [modalKey, setModalKey] = useState(0);
const findGroupById = (
groups: MasterModel.GroupNode[],
id: string,
): MasterModel.GroupNode | undefined => {
for (const group of groups) {
if (group.id === id) return group;
if (group.children) {
const found = findGroupById(group.children, id);
if (found) return found;
}
}
return undefined;
};
const handleUpdate = async (group: Partial<MasterModel.GroupNode>) => {
const key = 'update_group';
const { name, description, parent_id, code, short_name } = group;
const editGroup = parent_id
? {
...selectedItem,
name: name,
parent_id: parent_id,
metadata: {
code: code,
short_name: short_name,
},
description: description,
}
: {
...selectedItem,
name: name,
metadata: {
code: code,
short_name: short_name,
},
description: description,
};
try {
messageApi.open({
type: 'loading',
content: intl.formatMessage({
id: 'common.updating',
defaultMessage: 'updating...',
}),
key,
});
await apiUpdateGroup({ ...editGroup });
messageApi.open({
type: 'success',
content: intl.formatMessage({
id: 'master.groups.update.success',
defaultMessage: 'Updated successfully',
}),
key,
});
await getGroups();
return true;
} catch (error) {
messageApi.open({
type: 'error',
content: intl.formatMessage({
id: 'master.groups.update.failed',
defaultMessage: 'Updating failed, please try again!',
}),
key,
});
return false;
}
};
const showDeleteConfirm = (group: MasterModel.GroupNode) => {
Modal.confirm({
title: intl.formatMessage({
id: 'master.groups.delete.confirm',
defaultMessage: 'Are you sure delete this group',
}),
content: selectedItem?.name,
icon: <ExclamationCircleOutlined />,
okText: intl.formatMessage({ id: 'common.yes', defaultMessage: 'Yes' }),
okType: 'danger',
cancelText: intl.formatMessage({ id: 'common.no', defaultMessage: 'No' }),
async onOk() {
try {
const resp = await apiDeleteGroup(group.id);
if (resp.status === HTTPSTATUS.HTTP_NOCONTENT) {
messageApi.success(
intl.formatMessage({
id: 'master.groups.delete.success',
defaultMessage: 'Deleted group successfully',
}),
);
setSelectedItem(undefined);
await getGroups();
} else if (resp.status === HTTPSTATUS.HTTP_SERVERERROR) {
messageApi.warning(
intl.formatMessage({
id: 'master.groups.delete.failed_internal',
defaultMessage:
'The group contains devices or users and cannot be deleted',
}),
);
} else {
throw new Error('Delete group failed');
}
} catch (error) {
console.error('Error when delete group ', error);
messageApi.error(
intl.formatMessage({
id: 'master.groups.delete.failed',
defaultMessage: 'Delete group failed',
}),
);
}
},
});
};
const onItemClick = ({ key }: { key: string }) => {
const splitIndex = key.indexOf('-');
const action = key.substring(0, splitIndex);
const groupId = key.substring(splitIndex + 1);
const groupNode = findGroupById(groups || [], groupId);
console.log('GroupName', groupNode?.name);
setSelectedItem(groupNode);
setSelectedGroup(groupNode);
if (action === '1') {
setType('create-child');
} else if (action === '2') {
setType('update');
} else if (action === '3') {
showDeleteConfirm(groupNode!);
return;
}
// Increment modalKey to force remount component
setModalKey((prev) => prev + 1);
setHandleChildModal(true);
};
return (
<>
{contextHolder}
<CreateOrUpdateGroup
type={type}
key={modalKey}
isOpen={handleChildModal}
setIsOpen={setHandleChildModal}
group={selectedGroup}
message={messageApi}
/>
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard
colSpan={{ xs: 24, sm: 24, md: 10, lg: 10, xl: 10 }}
extra={[
currentUserProfile?.metadata?.user_type === 'sysadmin' && (
<Button
type="primary"
key="primary"
icon={<PlusOutlined />}
onClick={() => {
setType('create-root');
setSelectedItem(undefined);
setModalKey((prev) => prev + 1);
setHandleChildModal(true);
}}
>
<FormattedMessage
id="master.groups.root"
defaultMessage="Add root"
/>
</Button>
),
]}
>
<TreeGroup
titleRender={(item) => {
const groupNode = findGroupById(groups || [], item.key as string);
const menus: MenuItem[] = [
{
label: (
<Tooltip
title={
groupNode?.metadata?.has_thing
? intl.formatMessage({
id: 'master.groups.cannot-add-group',
defaultMessage:
'Cannot add child to a group that has things',
})
: undefined
}
>
{intl.formatMessage({
id: 'master.groups.add',
defaultMessage: 'Add child',
})}
</Tooltip>
),
key: `1-${groupNode?.id}`,
icon: <IconFont type="icon-leaf" />,
disabled: groupNode?.metadata?.has_thing === true,
},
{
label: intl.formatMessage({
id: 'common.edit',
defaultMessage: 'Update',
}),
key: `2-${groupNode?.id}`,
icon: <EditOutlined />,
},
{
label: (
<Tooltip
title={
groupNode?.metadata?.has_thing
? intl.formatMessage({
id: 'master.groups.delete.failed_internal',
defaultMessage:
'The group contains devices or users and cannot be deleted',
})
: undefined
}
>
{intl.formatMessage({
id: 'common.delete',
defaultMessage: 'Delete',
})}
</Tooltip>
),
key: `3-${groupNode?.id}`,
icon: <DeleteOutlined />,
disabled: groupNode?.metadata?.has_thing === true,
},
];
return (
<Dropdown
menu={{
items: menus,
onClick: onItemClick,
}}
trigger={['contextMenu']}
placement="bottomRight"
arrow
>
<span>
{typeof item.title === 'function'
? item.title(item)
: item.title}
</span>
</Dropdown>
);
}}
multiple={false}
onSelected={(value: string | string[] | null) => {
if (!groups) {
setSelectedItem(undefined);
return;
}
// Find the selected group from model
if (value && !Array.isArray(value)) {
const found = findGroupById(groups, value);
setSelectedItem(found);
} else {
setSelectedItem(undefined);
}
}}
/>
</ProCard>
<ProCard colSpan={{ xs: 24, sm: 24, md: 14, lg: 14, xl: 14 }}>
{selectedItem?.name && (
<>
<ProDescriptions<Partial<MasterModel.GroupNode>>
column={1}
bordered
title={selectedItem.name}
extra={[
<Tooltip
key="add"
title={
selectedItem?.metadata?.has_thing
? intl.formatMessage({
id: 'master.groups.cannot-add-group',
defaultMessage:
'Cannot add child to a group that has things',
})
: undefined
}
>
<Button
key="add"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setType('create-child');
setModalKey((prev) => prev + 1);
setHandleChildModal(true);
}}
disabled={
selectedItem.metadata?.has_thing === true ? true : false
}
>
<FormattedMessage
id="master.groups.add"
defaultMessage="Add Group"
/>
</Button>
</Tooltip>,
<Tooltip
key="add-fail"
title={
selectedItem?.metadata?.has_thing
? intl.formatMessage({
id: 'master.groups.delete.failed_internal',
defaultMessage:
'Cannot add child to a group that has things',
})
: undefined
}
>
<Button
danger
key="delete"
title={intl.formatMessage({
id: 'master.groups.delete.confirm',
defaultMessage: 'Delete this group?',
})}
onClick={() => {
showDeleteConfirm(selectedItem);
}}
icon={<DeleteOutlined />}
disabled={
selectedItem.metadata?.has_thing === true ? true : false
}
>
<FormattedMessage
id="common.delete"
defaultMessage="Delete"
/>
</Button>
,
</Tooltip>,
]}
dataSource={{
name: selectedItem.name,
code: selectedItem.metadata?.code || '',
short_name: selectedItem.metadata?.short_name || '',
description: selectedItem.description || '',
}}
columns={[
{
title: intl.formatMessage({
id: 'common.name',
defaultMessage: 'Name',
}),
dataIndex: 'name',
},
{
title: intl.formatMessage({
id: 'master.groups.code',
defaultMessage: 'Code',
}),
dataIndex: 'code',
},
{
title: intl.formatMessage({
id: 'master.groups.short_name',
defaultMessage: 'Short name',
}),
dataIndex: 'short_name',
},
{
title: intl.formatMessage({
id: 'common.description',
defaultMessage: 'Description',
}),
dataIndex: 'description',
},
]}
editable={{
onSave: async (_, record) => {
const updatedData: MasterModel.GroupNode = {
...selectedItem,
name: record.name || '',
description: record.description || '',
metadata: {
...selectedItem.metadata,
code: record.code,
short_name: record.short_name,
},
};
const success = await handleUpdate(updatedData);
if (success) {
setSelectedItem(updatedData);
return true;
}
return false;
},
}}
/>
</>
)}
</ProCard>
</ProCard>
</>
);
};
export default ManagerGroupPage;

View File

@@ -0,0 +1,484 @@
import { DATE_TIME_FORMAT, DEFAULT_PAGE_SIZE } from '@/constants';
import { apiQueryLogs } from '@/services/master/LogController';
import { apiQueryUsers } from '@/services/master/UserController';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { DatePicker } from 'antd/lib';
import dayjs from 'dayjs';
import { useRef } from 'react';
const SystemLogs = () => {
const intl = useIntl();
const tableRef = useRef<ActionType>();
const actions = [
//Alarm
{
title: intl.formatMessage({
id: 'master.logs.things.alarm.confirm',
defaultMessage: 'Alarm confirm',
}),
value: '0-0',
selectable: false,
children: [
{
value: 'things.alarm_confirm',
title: intl.formatMessage({
id: 'master.logs.things.confirm',
defaultMessage: 'Confirm',
}),
},
{
value: 'things.alarm_unconfirm',
title: intl.formatMessage({
id: 'master.logs.things.unconfirm',
defaultMessage: 'Unconfirm',
}),
},
],
},
//Things
{
title: intl.formatMessage({
id: 'master.logs.things',
defaultMessage: 'Things',
}),
value: '0-1',
selectable: false,
children: [
{
value: 'things.create',
title: intl.formatMessage({
id: 'master.logs.things.create',
defaultMessage: 'Create new thing',
}),
},
{
value: 'things.update',
title: intl.formatMessage({
id: 'master.logs.things.update',
defaultMessage: 'Update thing',
}),
},
{
value: 'things.remove',
title: intl.formatMessage({
id: 'master.logs.things.remove',
defaultMessage: 'Remove thing',
}),
},
{
value: 'things.share',
title: intl.formatMessage({
id: 'master.logs.things.share',
defaultMessage: 'Share thing',
}),
},
{
value: 'things.unshare',
title: intl.formatMessage({
id: 'master.logs.things.unshare',
defaultMessage: 'Unshare thing',
}),
},
{
value: 'things.update_key',
title: intl.formatMessage({
id: 'master.logs.things.update_key',
defaultMessage: 'Update key thing',
}),
},
],
},
// Users
{
title: intl.formatMessage({
id: 'master.logs.users',
defaultMessage: 'Users',
}),
value: '0-2',
selectable: false,
children: [
{
value: 'users.create',
title: intl.formatMessage({
id: 'master.logs.users.create',
defaultMessage: 'Register user',
}),
},
{
value: 'users.update',
title: intl.formatMessage({
id: 'master.logs.users.update',
defaultMessage: 'Update user',
}),
},
{
value: 'users.remove',
title: intl.formatMessage({
id: 'master.logs.users.remove',
defaultMessage: 'Remove user',
}),
},
{
value: 'users.login',
title: intl.formatMessage({
id: 'master.logs.users.login',
defaultMessage: 'User login',
}),
},
],
},
// Groups
{
title: intl.formatMessage({
id: 'master.logs.groups',
defaultMessage: 'Groups',
}),
value: '0-3',
selectable: false,
children: [
{
value: 'group.create',
title: intl.formatMessage({
id: 'master.logs.groups.create',
defaultMessage: 'Create new group',
}),
},
{
value: 'group.update',
title: intl.formatMessage({
id: 'master.logs.groups.update',
defaultMessage: 'Update group',
}),
},
{
value: 'group.remove',
title: intl.formatMessage({
id: 'master.logs.groups.remove',
defaultMessage: 'Remove group',
}),
},
{
value: 'group.assign_thing',
title: intl.formatMessage({
id: 'master.logs.groups.assign_thing',
defaultMessage: 'Assign thing to group',
}),
},
{
value: 'group.assign_user',
title: intl.formatMessage({
id: 'master.logs.groups.assign_user',
defaultMessage: 'Assign user to group',
}),
},
{
value: 'group.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.groups.unassign_thing',
defaultMessage: 'Remove thing from group',
}),
},
{
value: 'group.unassign_user',
title: intl.formatMessage({
id: 'master.logs.groups.unassign_user',
defaultMessage: 'Remove user from group',
}),
},
],
},
// Ships
{
title: intl.formatMessage({
id: 'master.logs.ships',
defaultMessage: 'Ships',
}),
value: '0-4',
selectable: false,
children: [
{
value: 'ships.create',
title: intl.formatMessage({
id: 'master.logs.ships.create',
defaultMessage: 'Create new ship',
}),
},
{
value: 'ships.update',
title: intl.formatMessage({
id: 'master.logs.ships.update',
defaultMessage: 'Update ship',
}),
},
{
value: 'ships.remove',
title: intl.formatMessage({
id: 'master.logs.ships.remove',
defaultMessage: 'Remove ship',
}),
},
{
value: 'ships.assign_thing',
title: intl.formatMessage({
id: 'master.logs.ships.assign_thing',
defaultMessage: 'Assign thing to ship',
}),
},
{
value: 'ships.assign_user',
title: intl.formatMessage({
id: 'master.logs.ships.assign_user',
defaultMessage: 'Assign user to ship',
}),
},
{
value: 'ships.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.ships.unassign_thing',
defaultMessage: 'Remove thing from ship',
}),
},
{
value: 'ships.unassign_user',
title: intl.formatMessage({
id: 'master.logs.ships.unassign_user',
defaultMessage: 'Remove user from ship',
}),
},
],
},
// Trips
{
title: intl.formatMessage({
id: 'master.logs.trips',
defaultMessage: 'Trips',
}),
value: '0-5',
selectable: false,
children: [
{
value: 'trips.create',
title: intl.formatMessage({
id: 'master.logs.trips.create',
defaultMessage: 'Create new ship',
}),
},
{
value: 'trips.update',
title: intl.formatMessage({
id: 'master.logs.trips.update',
defaultMessage: 'Update ship',
}),
},
{
value: 'trips.remove',
title: intl.formatMessage({
id: 'master.logs.trips.remove',
defaultMessage: 'Remove ship',
}),
},
{
value: 'trips.approve',
title: intl.formatMessage({
id: 'master.logs.trips.approve',
defaultMessage: 'Approve trip',
}),
},
{
value: 'trips.request_approve',
title: intl.formatMessage({
id: 'master.logs.trips.request_approve',
defaultMessage: 'Request approval for trip',
}),
},
{
value: 'trips.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.trips.unassign_thing',
defaultMessage: 'Remove thing from ship',
}),
},
{
value: 'trips.unassign_user',
title: intl.formatMessage({
id: 'master.logs.trips.unassign_user',
defaultMessage: 'Remove user from ship',
}),
},
],
},
];
const queryUserSource = async (): Promise<MasterModel.ProfileResponse[]> => {
try {
const body: MasterModel.SearchUserPaginationBody = {
offset: 0,
limit: 100,
order: 'email',
dir: 'asc',
};
const resp = await apiQueryUsers(body);
return resp?.users ?? [];
} catch {
return [];
}
};
const columns: ProColumns<MasterModel.Message>[] = [
{
title: intl.formatMessage({
id: 'master.logs.date.text',
defaultMessage: 'Date',
}),
key: 'dateTimeRange',
dataIndex: 'time',
width: '20%',
valueType: 'dateTimeRange',
initialValue: [dayjs().add(-1, 'day'), dayjs()],
search: {
transform: (value) => ({ startTime: value[0], endTime: value[1] }),
},
render: (_, row) => {
return dayjs.unix(row?.time || 0).format(DATE_TIME_FORMAT);
},
renderFormItem: (_, config, form) => {
return (
<DatePicker.RangePicker
width="50%"
presets={[
{
label: intl.formatMessage({
id: 'master.logs.search.yesterday',
defaultMessage: 'Yesterday',
}),
value: [dayjs().add(-1, 'day'), dayjs()],
},
{
label: intl.formatMessage({
id: 'master.logs.search.last_week',
defaultMessage: 'Last Week',
}),
value: [dayjs().add(-7, 'day'), dayjs()],
},
{
label: intl.formatMessage({
id: 'master.logs.search.last_month',
defaultMessage: 'Last Month',
}),
value: [dayjs().add(-30, 'day'), dayjs()],
},
]}
onChange={(dates) => {
form.setFieldsValue({ dateTimeRange: dates });
}}
/>
);
},
},
{
title: intl.formatMessage({
id: 'master.logs.email.text',
defaultMessage: 'Email',
}),
dataIndex: 'publisher',
valueType: 'select',
fieldProps: { mode: 'multiple' },
width: '25%',
request: async () => {
const users = await queryUserSource();
return users.map((u) => ({
label: u.email,
value: u.email,
}));
},
},
{
title: intl.formatMessage({
id: 'master.logs.action.text',
defaultMessage: 'Action',
}),
dataIndex: 'subtopic',
valueType: 'treeSelect',
width: '25%',
fieldProps: {
treeCheckable: true,
multiple: true,
},
request: async () => actions,
render: (_, item) => {
const childs = actions.flatMap((a) => a.children || []);
const action = childs.find((a) => a?.value === item.subtopic);
return action?.title ?? '...';
},
},
];
return (
<ProTable<MasterModel.Message>
actionRef={tableRef}
columns={columns}
rowKey={(item) =>
`${item.subtopic}:${item.time}:${item.publisher}:${item.name}`
}
size="large"
options={{
search: false,
setting: false,
density: false,
reload: true,
}}
pagination={{
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
showSizeChanger: true,
pageSizeOptions: ['10', '15', '20'],
showTotal: (total, range) =>
`${range[0]}-${range[1]}
${intl.formatMessage({
id: 'common.paginations.of',
defaultMessage: 'of',
})}
${total} ${intl.formatMessage({
id: 'master.logs.table.pagination',
defaultMessage: 'activities',
})}`,
}}
request={async (params) => {
try {
const { current, pageSize, subtopic, publisher, startTime, endTime } =
params;
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
const body: MasterModel.SearchLogPaginationBody = {
limit: pageSize,
offset: offset,
from: startTime
? typeof startTime === 'string'
? dayjs(startTime).unix()
: startTime.unix()
: undefined,
to: endTime
? typeof endTime === 'string'
? dayjs(endTime).unix()
: endTime.unix()
: undefined,
publisher: publisher?.length ? publisher.join(',') : undefined,
subtopic: subtopic?.length ? subtopic.join(',') : undefined,
};
const resp = await apiQueryLogs(body, 'user_logs');
return {
data: resp.messages || [],
success: true,
total: resp.total || 0,
};
} catch (error) {
console.error('Error when get logs: ', error);
return {
data: [],
success: false,
total: 0,
};
}
}}
/>
);
};
export default SystemLogs;

View File

@@ -0,0 +1,368 @@
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
import { HTTPSTATUS } from '@/constants';
import {
apiCreateUsers,
apiQueryUsers,
} from '@/services/master/UserController';
import { LockOutlined, PlusOutlined, UserOutlined } from '@ant-design/icons';
import {
ModalForm,
ProForm,
ProFormInstance,
ProFormSelect,
ProFormText,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button } from 'antd';
import { MessageInstance } from 'antd/es/message/interface';
import { useRef, useState } from 'react';
type CreateUserProps = {
message: MessageInstance;
onSuccess?: (isSuccess: boolean) => void;
};
type CreateUserFormValues = {
full_name: string;
phone_number: string;
user_type: 'admin' | 'enduser' | 'sysadmin' | 'users';
email: string;
password: string;
group_id?: string;
};
const RoleSelectForm = () => {
const domain = process.env.DOMAIN_ENV;
const intl = useIntl();
switch (domain) {
case 'sgw':
return (
<ProFormSelect
name="user_type"
label={intl.formatMessage({ id: 'master.users.role' })}
options={[
{
label: intl.formatMessage({
id: 'master.users.role.sgw.end_user',
}),
value: 'endusers',
},
{
label: intl.formatMessage({ id: 'master.users.role.user' }),
value: 'users',
},
{
label: intl.formatMessage({ id: 'master.users.role.admin' }),
value: 'admin',
},
]}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.users.role.placeholder',
defaultMessage: 'Please select a role',
}),
},
]}
/>
);
case 'spole':
return (
<ProFormSelect
name="user_type"
label={intl.formatMessage({ id: 'master.users.role' })}
options={[
{
label: intl.formatMessage({ id: 'master.users.role.user' }),
value: 'users',
},
{
label: intl.formatMessage({ id: 'master.users.role.admin' }),
value: 'admin',
},
]}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.users.role.placeholder',
defaultMessage: 'Please select a role',
}),
},
]}
/>
);
case 'gms':
default:
return (
<ProFormSelect
name="user_type"
label={intl.formatMessage({ id: 'master.users.role' })}
options={[
{
label: intl.formatMessage({ id: 'master.users.role.user' }),
value: 'users',
},
{
label: intl.formatMessage({ id: 'master.users.role.admin' }),
value: 'admin',
},
]}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.users.role.placeholder',
defaultMessage: 'Please select a role',
}),
},
]}
/>
);
}
};
const CreateUser = ({ message, onSuccess }: CreateUserProps) => {
const formRef = useRef<ProFormInstance<CreateUserFormValues>>();
const intl = useIntl();
const [group_id, setGroupId] = useState<string | string[] | null>(null);
const handleGroupSelect = (group: string | string[] | null) => {
setGroupId(group);
// Đồng bộ giá trị vào form để validation hoạt động
formRef.current?.setFieldsValue({
group_id: Array.isArray(group) ? group.join(',') : group || undefined,
});
};
return (
<ModalForm<CreateUserFormValues>
title={intl.formatMessage({
id: 'master.users.register.title',
defaultMessage: 'Register New User',
})}
formRef={formRef}
trigger={
<Button type="primary" key="primary" icon={<PlusOutlined />}>
<FormattedMessage
id="master.users.register"
defaultMessage="Register"
/>
</Button>
}
autoFocusFirstInput
onFinish={async (values: CreateUserFormValues) => {
const body: MasterModel.CreateUserBodyRequest = {
email: values.email,
password: values.password,
metadata: {
full_name: values.full_name,
phone_number: values.phone_number,
user_type: values.user_type || 'enduser',
group_id: values.group_id,
},
};
try {
const resp = await apiCreateUsers(body);
if (resp.status === HTTPSTATUS.HTTP_CREATED) {
message.success(
intl.formatMessage({
id: 'master.users.create.success',
defaultMessage: 'Create user successfully',
}),
);
formRef.current?.resetFields();
onSuccess?.(true);
return true;
} else {
throw new Error('Create user failed');
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'master.users.create.error',
defaultMessage: 'Failed to create user',
}),
);
onSuccess?.(false);
return false;
}
}}
>
<ProFormText
name={'full_name'}
label={intl.formatMessage({
id: 'master.users.full_name',
defaultMessage: 'Full name',
})}
placeholder={intl.formatMessage({
id: 'master.users.full_name.placeholder',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.users.full_name.required',
defaultMessage: 'The full name is required',
}),
},
]}
/>
<RoleSelectForm />
<ProFormText
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.email.required"
defaultMessage="The email is required"
/>
),
},
{
type: 'email',
message: (
<FormattedMessage
id="master.users.email.invalid"
defaultMessage="Invalid email address"
/>
),
},
{
validator: async (rule, value) => {
if (value) {
const query: MasterModel.SearchUserPaginationBody = {
offset: 0,
limit: 1,
order: 'email',
email: value,
dir: 'asc',
};
const resp = await apiQueryUsers(query);
const { total } = resp;
if (total && total > 0) {
return Promise.reject(
new Error(
intl.formatMessage({
id: 'master.users.email.exists',
defaultMessage: 'The email is exists',
}),
),
);
}
}
return Promise.resolve();
},
},
]}
name="email"
label={intl.formatMessage({
id: 'master.users.email',
defaultMessage: 'Email',
})}
fieldProps={{
prefix: <UserOutlined />,
}}
placeholder={intl.formatMessage({
id: 'master.users.email.placeholder',
defaultMessage: 'Email',
})}
validateTrigger={['onBlur']}
/>
<ProFormText
name="phone_number"
label={intl.formatMessage({
id: 'master.users.phone_number',
defaultMessage: 'Phone number',
})}
fieldProps={{
autoComplete: 'phone-number',
}}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.users.phone_number.required',
defaultMessage: 'The phone number is required',
}),
},
{
pattern: /((09|03|07|08|05)+([0-9]{8})\b)/g,
message: intl.formatMessage({
id: 'master.users.phone_number.notvalid',
defaultMessage: 'Invalid phone number',
}),
},
]}
/>
<ProFormText.Password
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.password"
defaultMessage="Password is required"
/>
),
},
{
pattern: /^(?=.*[0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*.]{6,16}$/g,
message: intl.formatMessage({
id: 'master.profile.change-password.password.strong',
defaultMessage:
'A password contains at least 8 characters, including at least one number and includes both lower and uppercase letters and special characters, for example #, ?, !',
}),
},
{
validator: async (rule, value) => {
if (value && value.length < 8) {
return Promise.reject(
intl.formatMessage({
id: 'master.users.password.minimum',
defaultMessage: 'Minimum password length is 8',
}),
);
}
return Promise.resolve();
},
},
]}
name="password"
label={intl.formatMessage({
id: 'master.users.password',
defaultMessage: 'Password',
})}
fieldProps={{
prefix: <LockOutlined />,
autoComplete: 'current-password',
}}
placeholder={intl.formatMessage({
id: 'master.users.password.placeholder',
defaultMessage: 'Password',
})}
/>
<ProForm.Item
name="group_id"
label={intl.formatMessage({
id: 'master.users.groups',
defaultMessage: 'Groups',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.users.groups.required',
defaultMessage: 'Please select groups!',
}),
},
]}
>
<TreeSelectedGroup groupIds={group_id} onSelected={handleGroupSelect} />
</ProForm.Item>
</ModalForm>
);
};
export default CreateUser;

Some files were not shown because too many files have changed in this diff Show More