Compare commits
8 Commits
6691122c8f
...
anhlt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a11e2c2991 | ||
| c9aeca0ed9 | |||
|
|
fea9cca865 | ||
| 1f35516e44 | |||
|
|
17d246d5ef | ||
|
|
b0b09a86b7 | ||
|
|
1a06328c77 | ||
|
|
e5b388505a |
@@ -3,6 +3,7 @@ import {
|
||||
alarmsRoute,
|
||||
commonManagerRoutes,
|
||||
loginRoute,
|
||||
managerCameraRoute,
|
||||
managerRouteBase,
|
||||
notFoundRoute,
|
||||
profileRoute,
|
||||
@@ -25,7 +26,7 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
...managerRouteBase,
|
||||
routes: [...commonManagerRoutes],
|
||||
routes: [...commonManagerRoutes, managerCameraRoute],
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
|
||||
@@ -23,6 +23,13 @@ export default defineConfig({
|
||||
path: '/map',
|
||||
component: './Slave/SGW/Map',
|
||||
},
|
||||
{
|
||||
name: 'sgw.ships',
|
||||
icon: 'icon-ship',
|
||||
path: '/ships',
|
||||
component: './Slave/SGW/Ship',
|
||||
access: 'canEndUser_User',
|
||||
},
|
||||
{
|
||||
name: 'sgw.trips',
|
||||
icon: 'icon-trip',
|
||||
|
||||
@@ -80,6 +80,11 @@ export const commonManagerRoutes = [
|
||||
},
|
||||
];
|
||||
|
||||
export const managerCameraRoute = {
|
||||
path: '/manager/devices/:thingId/camera',
|
||||
component: './Manager/Device/Camera',
|
||||
};
|
||||
|
||||
export const managerRouteBase = {
|
||||
name: 'manager',
|
||||
icon: 'icon-setting',
|
||||
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
} from './utils/storage';
|
||||
const isProdBuild = process.env.NODE_ENV === 'production';
|
||||
export type InitialStateResponse = {
|
||||
getUserProfile?: () => Promise<MasterModel.ProfileResponse | undefined>;
|
||||
currentUserProfile?: MasterModel.ProfileResponse;
|
||||
getUserProfile?: () => Promise<MasterModel.UserResponse | undefined>;
|
||||
currentUserProfile?: MasterModel.UserResponse;
|
||||
theme?: 'light' | 'dark';
|
||||
};
|
||||
|
||||
@@ -86,7 +86,7 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
|
||||
contentWidth: 'Fluid',
|
||||
navTheme: isDark ? 'realDark' : 'light',
|
||||
splitMenus: true,
|
||||
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_pw36kpy0e7h.js',
|
||||
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_pwy498d2aw.js',
|
||||
contentStyle: {
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
|
||||
BIN
src/assets/alarm_icon.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src/assets/exclamation.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
src/assets/marker.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/ship_alarm.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
src/assets/ship_alarm_2.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/ship_alarm_fishing.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
BIN
src/assets/ship_online.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src/assets/ship_online_fishing.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src/assets/ship_undefine.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
src/assets/ship_warning.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
BIN
src/assets/ship_warning_fishing.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
src/assets/sos_icon.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
src/assets/warning_icon.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
@@ -13,7 +13,7 @@ import { Dropdown } from 'antd';
|
||||
export const AvatarDropdown = ({
|
||||
currentUserProfile,
|
||||
}: {
|
||||
currentUserProfile?: MasterModel.ProfileResponse;
|
||||
currentUserProfile?: MasterModel.UserResponse;
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFromIconfontCN } from '@ant-design/icons';
|
||||
|
||||
const IconFont = createFromIconfontCN({
|
||||
scriptUrl: '//at.alicdn.com/t/c/font_5096559_fvnh1x2eqer.js',
|
||||
scriptUrl: '//at.alicdn.com/t/c/font_5096559_pwy498d2aw.js',
|
||||
});
|
||||
|
||||
export default IconFont;
|
||||
|
||||
62
src/components/shared/Button.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { Button, Popconfirm, Tooltip } from 'antd';
|
||||
import { useState } from 'react';
|
||||
|
||||
/* =======================
|
||||
DeleteButton
|
||||
======================= */
|
||||
|
||||
interface DeleteButtonProps {
|
||||
title: string;
|
||||
text: string;
|
||||
onOk: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const DeleteButton: React.FC<DeleteButtonProps> = ({
|
||||
title,
|
||||
text,
|
||||
onOk,
|
||||
}) => {
|
||||
const [visible, setVisible] = useState<boolean>(false);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
await onOk();
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popconfirm
|
||||
title={title}
|
||||
open={visible}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setVisible(false)}
|
||||
>
|
||||
<Tooltip title={text}>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
type="primary"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => setVisible(true)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
);
|
||||
};
|
||||
|
||||
/* =======================
|
||||
EditButton
|
||||
======================= */
|
||||
|
||||
interface EditButtonProps {
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export const EditButton: React.FC<EditButtonProps> = ({ text, onClick }) => {
|
||||
return (
|
||||
<Tooltip title={text}>
|
||||
<Button size="small" icon={<EditOutlined />} onClick={onClick} />
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
298
src/components/shared/PhotoActionModal.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
import { HTTPSTATUS } from '@/const';
|
||||
import {
|
||||
apiDeletePhoto,
|
||||
apiGetPhoto,
|
||||
apiGetTagsPhoto,
|
||||
apiUploadPhoto,
|
||||
} from '@/services/slave/sgw/PhotoController';
|
||||
import { ModalForm, ProFormUploadButton } from '@ant-design/pro-components';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Divider, message } from 'antd';
|
||||
import { UploadFile } from 'antd/lib';
|
||||
import { useState } from 'react';
|
||||
|
||||
type PhotoActionModalProps = {
|
||||
isOpen?: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
type: SgwModel.PhotoGetParams['type'];
|
||||
id: string | number;
|
||||
hasSubPhotos?: boolean;
|
||||
};
|
||||
const PhotoActionModal = ({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
type,
|
||||
id,
|
||||
hasSubPhotos = true,
|
||||
}: PhotoActionModalProps) => {
|
||||
const [imageMain, setImageMain] = useState<UploadFile[]>([]);
|
||||
const [imageSubs, setImageSubs] = useState<UploadFile[]>([]);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const intl = useIntl();
|
||||
const fetchImageByTag = async (tag: string): Promise<UploadFile | null> => {
|
||||
try {
|
||||
const resp = await apiGetPhoto(type, id, tag);
|
||||
if (resp.status !== HTTPSTATUS.HTTP_SUCCESS) {
|
||||
return null;
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(
|
||||
new Blob([resp.data], { type: 'image/jpeg' }),
|
||||
);
|
||||
return {
|
||||
uid: `-${tag}`,
|
||||
name: `${tag}.jpg`,
|
||||
status: 'done',
|
||||
url: objectUrl,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadPhoto = async (
|
||||
file: UploadFile,
|
||||
tag: string,
|
||||
): Promise<boolean> => {
|
||||
try {
|
||||
const resp = await apiUploadPhoto(
|
||||
type,
|
||||
String(id),
|
||||
file.originFileObj as File,
|
||||
tag,
|
||||
);
|
||||
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||
return true;
|
||||
}
|
||||
throw new Error('Upload photo failed');
|
||||
} catch (error) {
|
||||
console.error('Error when upload image: ', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const handleDeletePhoto = async (tag: string): Promise<boolean> => {
|
||||
try {
|
||||
const resp = await apiDeletePhoto(type, String(id), tag);
|
||||
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||
return true;
|
||||
}
|
||||
throw new Error('Delete photo failed');
|
||||
} catch (error) {
|
||||
console.error('Error when delete image: ', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalForm
|
||||
open={isOpen}
|
||||
submitter={false}
|
||||
onOpenChange={setIsOpen}
|
||||
layout="vertical"
|
||||
request={async () => {
|
||||
// 1. Lấy ảnh chính (tag 'main')
|
||||
const mainImage = await fetchImageByTag('main');
|
||||
setImageMain(mainImage ? [mainImage] : []);
|
||||
|
||||
if (hasSubPhotos) {
|
||||
// 2. Lấy danh sách tags
|
||||
try {
|
||||
const tagsResp = await apiGetTagsPhoto(type, id);
|
||||
if (tagsResp?.tags && Array.isArray(tagsResp.tags)) {
|
||||
// Lọc bỏ tag 'main' và lấy các tag còn lại
|
||||
const subTags = tagsResp.tags.filter(
|
||||
(tag: string) => tag !== 'main',
|
||||
);
|
||||
|
||||
// 3. Lấy ảnh cho từng tag phụ
|
||||
const subImages: UploadFile[] = [];
|
||||
for (const tag of subTags) {
|
||||
const img = await fetchImageByTag(tag);
|
||||
if (img) {
|
||||
subImages.push(img);
|
||||
}
|
||||
}
|
||||
setImageSubs(subImages);
|
||||
}
|
||||
} catch {
|
||||
// Không có tags hoặc lỗi
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}}
|
||||
>
|
||||
{contextHolder}
|
||||
<Divider>
|
||||
{intl.formatMessage({ id: 'photo.main', defaultMessage: 'Ảnh chính' })}
|
||||
</Divider>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<ProFormUploadButton
|
||||
name="main-picture"
|
||||
label={null}
|
||||
title={intl.formatMessage({
|
||||
id: 'photo.upload',
|
||||
defaultMessage: 'Chọn ảnh',
|
||||
})}
|
||||
accept="image/*"
|
||||
max={1}
|
||||
transform={(value) => ({ upload: value })}
|
||||
fieldProps={{
|
||||
onChange: async (info) => {
|
||||
if (info.file.status !== 'removed') {
|
||||
const isSuccess = await handleUploadPhoto(info.file, 'main');
|
||||
if (!isSuccess) {
|
||||
messageApi.error(
|
||||
intl.formatMessage({
|
||||
id: 'photo.update_fail',
|
||||
defaultMessage: 'Cập nhật ảnh thất bại',
|
||||
}),
|
||||
);
|
||||
setImageMain([]);
|
||||
} else {
|
||||
messageApi.success(
|
||||
intl.formatMessage({
|
||||
id: 'photo.update_success',
|
||||
defaultMessage: 'Cập nhật ảnh thành công',
|
||||
}),
|
||||
);
|
||||
// Dùng luôn file local để tạo URL, đỡ gọi API
|
||||
const objectUrl = URL.createObjectURL(
|
||||
info.file.originFileObj as File,
|
||||
);
|
||||
setImageMain([
|
||||
{
|
||||
uid: info.file.uid,
|
||||
name: info.file.name,
|
||||
status: 'done',
|
||||
url: objectUrl,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
},
|
||||
listType: 'picture-card',
|
||||
fileList: imageMain,
|
||||
maxCount: 1,
|
||||
onRemove: async () => {
|
||||
const isSuccess = await handleDeletePhoto('main');
|
||||
if (!isSuccess) {
|
||||
messageApi.error(
|
||||
intl.formatMessage({
|
||||
id: 'photo.delete_fail',
|
||||
defaultMessage: 'Xóa ảnh thất bại',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
messageApi.success(
|
||||
intl.formatMessage({
|
||||
id: 'photo.delete_success',
|
||||
defaultMessage: 'Xóa ảnh thành công',
|
||||
}),
|
||||
);
|
||||
setImageMain([]);
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{hasSubPhotos && (
|
||||
<>
|
||||
<Divider>
|
||||
{intl.formatMessage({ id: 'photo.sub', defaultMessage: 'Ảnh phụ' })}
|
||||
</Divider>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: imageSubs.length > 0 ? 'flex-start' : 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<ProFormUploadButton
|
||||
name="sub-picture"
|
||||
label={null}
|
||||
title={intl.formatMessage({
|
||||
id: 'photo.upload',
|
||||
defaultMessage: 'Chọn ảnh',
|
||||
})}
|
||||
accept="image/*"
|
||||
max={10}
|
||||
transform={(value) => ({ upload: value })}
|
||||
fieldProps={{
|
||||
onChange: async (info) => {
|
||||
// Tìm file mới được thêm (so sánh với imageSubs hiện tại)
|
||||
const existingUids = new Set(imageSubs.map((f) => f.uid));
|
||||
const newFiles = info.fileList.filter(
|
||||
(f) => !existingUids.has(f.uid) && f.originFileObj,
|
||||
);
|
||||
|
||||
for (const file of newFiles) {
|
||||
// Tạo tag random
|
||||
const randomTag = `sub_${Date.now()}_${Math.random()
|
||||
.toString(36)
|
||||
.substring(2, 9)}`;
|
||||
const isSuccess = await handleUploadPhoto(file, randomTag);
|
||||
if (!isSuccess) {
|
||||
messageApi.error(
|
||||
intl.formatMessage({
|
||||
id: 'photo.update_fail',
|
||||
defaultMessage: 'Cập nhật ảnh thất bại',
|
||||
}),
|
||||
);
|
||||
setImageSubs(imageSubs); // Revert về state cũ
|
||||
return;
|
||||
}
|
||||
messageApi.success(
|
||||
intl.formatMessage({
|
||||
id: 'photo.update_success',
|
||||
defaultMessage: 'Thêm ảnh thành công',
|
||||
}),
|
||||
);
|
||||
// Cập nhật file với tag
|
||||
(file as any).tag = randomTag;
|
||||
file.url = URL.createObjectURL(file.originFileObj as File);
|
||||
file.status = 'done';
|
||||
}
|
||||
setImageSubs(info.fileList);
|
||||
},
|
||||
listType: 'picture-card',
|
||||
fileList: imageSubs,
|
||||
onRemove: async (file) => {
|
||||
// Lấy tag từ file (được lưu khi fetch hoặc upload)
|
||||
const tag = (file as any).tag || file.uid?.replace(/^-/, '');
|
||||
const isSuccess = await handleDeletePhoto(tag);
|
||||
if (!isSuccess) {
|
||||
messageApi.error(
|
||||
intl.formatMessage({
|
||||
id: 'photo.delete_fail',
|
||||
defaultMessage: 'Xóa ảnh thất bại',
|
||||
}),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
messageApi.success(
|
||||
intl.formatMessage({
|
||||
id: 'photo.delete_success',
|
||||
defaultMessage: 'Xóa ảnh thành công',
|
||||
}),
|
||||
);
|
||||
setImageSubs((prev) =>
|
||||
prev.filter((f) => f.uid !== file.uid),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhotoActionModal;
|
||||
205
src/components/shared/TagState.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
AlertOutlined,
|
||||
CheckOutlined,
|
||||
DisconnectOutlined,
|
||||
ExclamationOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Flex, Tag, theme, Tooltip } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { TagStateCallbackPayload } from '../../pages/Slave/SGW/Map/type';
|
||||
|
||||
type TagStateProps = {
|
||||
normalCount?: number;
|
||||
warningCount?: number;
|
||||
criticalCount?: number;
|
||||
sosCount?: number;
|
||||
disconnectedCount?: number;
|
||||
onTagPress?: (selection: TagStateCallbackPayload) => void;
|
||||
};
|
||||
|
||||
const TagState = ({
|
||||
normalCount = 0,
|
||||
warningCount = 0,
|
||||
criticalCount = 0,
|
||||
sosCount,
|
||||
disconnectedCount = 0,
|
||||
onTagPress,
|
||||
}: TagStateProps) => {
|
||||
const [activeStates, setActiveStates] = useState({
|
||||
normal: false,
|
||||
warning: false,
|
||||
critical: false,
|
||||
sos: false,
|
||||
disconnected: false,
|
||||
});
|
||||
const { token } = theme.useToken();
|
||||
const intl = useIntl();
|
||||
|
||||
// Style variants using antd theme tokens for dark mode support
|
||||
const getTagStyle = (
|
||||
type: 'normal' | 'warning' | 'critical' | 'offline',
|
||||
isActive: boolean,
|
||||
) => {
|
||||
const baseStyle = {
|
||||
borderRadius: token.borderRadiusSM,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid' as const,
|
||||
};
|
||||
|
||||
if (type === 'normal') {
|
||||
return {
|
||||
...baseStyle,
|
||||
color: isActive ? token.colorSuccess : token.colorSuccess,
|
||||
backgroundColor: isActive
|
||||
? token.colorSuccessBg
|
||||
: token.colorBgContainer,
|
||||
borderColor: token.colorSuccessBorder,
|
||||
};
|
||||
}
|
||||
if (type === 'warning') {
|
||||
return {
|
||||
...baseStyle,
|
||||
color: isActive ? token.colorWarning : token.colorWarning,
|
||||
backgroundColor: isActive
|
||||
? token.colorWarningBg
|
||||
: token.colorBgContainer,
|
||||
borderColor: token.colorWarningBorder,
|
||||
};
|
||||
}
|
||||
if (type === 'critical') {
|
||||
return {
|
||||
...baseStyle,
|
||||
color: isActive ? token.colorError : token.colorError,
|
||||
backgroundColor: isActive ? token.colorErrorBg : token.colorBgContainer,
|
||||
borderColor: token.colorErrorBorder,
|
||||
};
|
||||
}
|
||||
// offline
|
||||
return {
|
||||
...baseStyle,
|
||||
color: token.colorTextSecondary,
|
||||
backgroundColor: isActive
|
||||
? token.colorFillSecondary
|
||||
: token.colorBgContainer,
|
||||
borderColor: token.colorBorder,
|
||||
};
|
||||
};
|
||||
|
||||
const handleTagClick = (key: keyof typeof activeStates) => {
|
||||
const newStates = { ...activeStates, [key]: !activeStates[key] };
|
||||
setActiveStates(newStates);
|
||||
if (onTagPress) {
|
||||
onTagPress({
|
||||
isNormal: newStates.normal,
|
||||
isWarning: newStates.warning,
|
||||
isCritical: newStates.critical,
|
||||
isSos: newStates.sos,
|
||||
isDisconnected: newStates.disconnected,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap={1}
|
||||
style={{
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
zIndex: 20,
|
||||
flexWrap: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{/* Only show SOS tag if sosCount is provided (SGW environment) */}
|
||||
{sosCount !== undefined && (
|
||||
<Tooltip
|
||||
title={intl.formatMessage({
|
||||
id: 'common.level.sos',
|
||||
defaultMessage: 'SOS',
|
||||
})}
|
||||
>
|
||||
<Tag.CheckableTag
|
||||
style={getTagStyle('critical', activeStates.sos)}
|
||||
icon={<AlertOutlined />}
|
||||
checked={activeStates.sos}
|
||||
onChange={() => handleTagClick('sos')}
|
||||
>
|
||||
{`${sosCount}`}
|
||||
</Tag.CheckableTag>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* {normalCount > 0 && ( */}
|
||||
<Tooltip
|
||||
title={intl.formatMessage({
|
||||
id: 'common.level.normal',
|
||||
defaultMessage: 'Normal',
|
||||
})}
|
||||
>
|
||||
<Tag.CheckableTag
|
||||
style={getTagStyle('normal', activeStates.normal)}
|
||||
icon={<CheckOutlined />}
|
||||
checked={activeStates.normal}
|
||||
onChange={() => handleTagClick('normal')}
|
||||
>
|
||||
{`${normalCount}`}
|
||||
</Tag.CheckableTag>
|
||||
</Tooltip>
|
||||
|
||||
{/* {warningCount > 0 && ( */}
|
||||
<Tooltip
|
||||
title={intl.formatMessage({
|
||||
id: 'common.level.warning',
|
||||
defaultMessage: 'Warning',
|
||||
})}
|
||||
>
|
||||
<Tag.CheckableTag
|
||||
style={getTagStyle('warning', activeStates.warning)}
|
||||
icon={<WarningOutlined />}
|
||||
checked={activeStates.warning}
|
||||
onChange={() => handleTagClick('warning')}
|
||||
>
|
||||
{`${warningCount}`}
|
||||
</Tag.CheckableTag>
|
||||
</Tooltip>
|
||||
|
||||
{/* {criticalCount > 0 && ( */}
|
||||
<Tooltip
|
||||
title={intl.formatMessage({
|
||||
id: 'common.level.critical',
|
||||
defaultMessage: 'Critical',
|
||||
})}
|
||||
>
|
||||
<Tag.CheckableTag
|
||||
style={getTagStyle('critical', activeStates.critical)}
|
||||
icon={<ExclamationOutlined />}
|
||||
checked={activeStates.critical}
|
||||
onChange={() => handleTagClick('critical')}
|
||||
>
|
||||
{`${criticalCount}`}
|
||||
</Tag.CheckableTag>
|
||||
</Tooltip>
|
||||
|
||||
{/* {disconnectedCount > 0 && ( */}
|
||||
<Tooltip
|
||||
title={intl.formatMessage({
|
||||
id: 'common.level.disconnected',
|
||||
defaultMessage: 'Disconnected',
|
||||
})}
|
||||
>
|
||||
<Tag.CheckableTag
|
||||
style={getTagStyle('offline', activeStates.disconnected)}
|
||||
icon={<DisconnectOutlined />}
|
||||
checked={activeStates.disconnected}
|
||||
onChange={() => handleTagClick('disconnected')}
|
||||
>
|
||||
{`${disconnectedCount}`}
|
||||
</Tag.CheckableTag>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
export default TagState;
|
||||
32
src/components/shared/ThingShared.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
STATUS_DANGEROUS,
|
||||
STATUS_NORMAL,
|
||||
STATUS_SOS,
|
||||
STATUS_WARNING,
|
||||
} from '@/constants';
|
||||
import { Badge } from 'antd';
|
||||
import IconFont from '../IconFont';
|
||||
|
||||
export const getBadgeStatus = (status: number) => {
|
||||
switch (status) {
|
||||
case STATUS_NORMAL:
|
||||
return <Badge size="default" status="success" />;
|
||||
case STATUS_WARNING:
|
||||
return <Badge size="default" status="warning" />;
|
||||
case STATUS_DANGEROUS:
|
||||
return <Badge size="default" status="error" />;
|
||||
case STATUS_SOS:
|
||||
return <Badge size="default" status="error" />;
|
||||
default:
|
||||
return <Badge size="default" status="default" />;
|
||||
}
|
||||
};
|
||||
|
||||
export const getBadgeConnection = (online: boolean) => {
|
||||
switch (online) {
|
||||
case true:
|
||||
return <Badge status="processing" />;
|
||||
default:
|
||||
return <IconFont type="icon-cloud-disconnect" />;
|
||||
}
|
||||
};
|
||||
78
src/components/shared/index.less
Normal file
@@ -0,0 +1,78 @@
|
||||
.italic {
|
||||
//font-style: italic;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.disconnected {
|
||||
color: rgb(146, 143, 143);
|
||||
//background-color: rgb(219, 220, 222);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.cursor-pointer-row .ant-table-tbody > tr) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.normalActive {
|
||||
color: #52c41a;
|
||||
background: #e0fec3;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
|
||||
.warningActive {
|
||||
color: #faad14;
|
||||
background: #f8ebaa;
|
||||
border-color: #ffe58f;
|
||||
}
|
||||
|
||||
.criticalActive {
|
||||
color: #ff4d4f;
|
||||
background: #f9b9b0;
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
|
||||
.normal {
|
||||
color: #52c41a;
|
||||
background: #fff;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #faad14;
|
||||
background: #fff;
|
||||
border-color: #ffe58f;
|
||||
}
|
||||
|
||||
.critical {
|
||||
color: #ff4d4f;
|
||||
background: #fff;
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
|
||||
.offline {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 88%);
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.offlineActive {
|
||||
background: rgb(190, 190, 190);
|
||||
color: rgba(0, 0, 0, 88%);
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.online {
|
||||
background: #fff;
|
||||
color: #1677ff;
|
||||
border-color: #91caff;
|
||||
}
|
||||
|
||||
.onlineActive {
|
||||
background: #c6e1f5;
|
||||
color: #1677ff;
|
||||
border-color: #91caff;
|
||||
}
|
||||
|
||||
.table-row-select tbody tr:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
2
src/const.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Re-export from constants for backward compatibility
|
||||
export * from './constants';
|
||||
@@ -5,6 +5,9 @@ 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 DURATION_DISCONNECTED = 300; //seconds
|
||||
export const DURATION_POLLING_PRESENTATIONS = 120000; //milliseconds
|
||||
|
||||
export const STATUS_NORMAL = 0;
|
||||
export const STATUS_WARNING = 1;
|
||||
export const STATUS_DANGEROUS = 2;
|
||||
|
||||
@@ -4,3 +4,12 @@ export enum SGW_ROLE {
|
||||
USERS = 'users',
|
||||
ENDUSER = 'enduser',
|
||||
}
|
||||
|
||||
export enum SGW_STATUS {
|
||||
CREATE_FISHING_LOG_SUCCESS = 'CREATE_FISHING_LOG_SUCCESS',
|
||||
CREATE_FISHING_LOG_FAIL = 'CREATE_FISHING_LOG_FAIL',
|
||||
START_TRIP_SUCCESS = 'START_TRIP_SUCCESS',
|
||||
START_TRIP_FAIL = 'START_TRIP_FAIL',
|
||||
UPDATE_FISHING_LOG_SUCCESS = 'UPDATE_FISHING_LOG_SUCCESS',
|
||||
UPDATE_FISHING_LOG_FAIL = 'UPDATE_FISHING_LOG_FAIL',
|
||||
}
|
||||
|
||||
@@ -2,3 +2,36 @@ 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';
|
||||
|
||||
// API Routes
|
||||
export const SGW_ROUTE_PORTS = '/api/sgw/ports';
|
||||
export const SGW_ROUTE_SHIPS = '/api/sgw/ships';
|
||||
export const SGW_ROUTE_SHIP_TYPES = '/api/sgw/ships/types';
|
||||
export const SGW_ROUTE_SHIP_GROUPS = '/api/sgw/shipsgroup';
|
||||
|
||||
// Trip API Routes
|
||||
export const SGW_ROUTE_TRIPS = '/api/sgw/trips';
|
||||
export const SGW_ROUTE_TRIPS_LIST = '/api/sgw/tripslist';
|
||||
export const SGW_ROUTE_TRIPS_LAST = '/api/sgw/trips/last';
|
||||
export const SGW_ROUTE_TRIPS_BY_ID = '/api/sgw/trips-by-id';
|
||||
export const SGW_ROUTE_UPDATE_TRIP_STATUS = '/api/sgw/update-trip-status';
|
||||
export const SGW_ROUTE_HAUL_HANDLE = '/api/sgw/haul-handle';
|
||||
export const SGW_ROUTE_GET_FISH = '/api/sgw/fishspecies-list';
|
||||
export const SGW_ROUTE_UPDATE_FISHING_LOGS = '/api/sgw/update-fishing-logs';
|
||||
|
||||
// Crew API Routes
|
||||
export const SGW_ROUTE_CREW = '/api/sgw/crew';
|
||||
export const SGW_ROUTE_TRIP_CREW = '/api/sgw/tripcrew';
|
||||
export const SGW_ROUTE_TRIPS_CREWS = '/api/sgw/trips/crews';
|
||||
|
||||
// Photo API Routes
|
||||
export const SGW_ROUTE_PHOTO = '/api/sgw/photo';
|
||||
export const SGW_ROUTE_PHOTO_TAGS = '/api/sgw/list-photo';
|
||||
|
||||
// Banzone API Routes
|
||||
export const SGW_ROUTE_BANZONES = '/api/sgw/banzones';
|
||||
export const SGW_ROUTE_BANZONES_LIST = '/api/sgw/banzones/list';
|
||||
|
||||
// Fish API Routes
|
||||
export const SGW_ROUTE_CREATE_OR_UPDATE_FISH = '/api/sgw/fishspecies';
|
||||
export const SGW_ROUTE_CREATE_OR_UPDATE_FISHING_LOGS = '/api/sgw/fishingLog';
|
||||
|
||||
1
src/constants/slave/sgw/websocket.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const SHIP_SOS_WS_URL = 'wss://sgw.gms.vn/thingscache';
|
||||
@@ -33,11 +33,15 @@ export default {
|
||||
'common.theme.dark': 'Dark Theme',
|
||||
'common.paginations.things': 'things',
|
||||
'common.paginations.of': 'of',
|
||||
'common.of': 'of',
|
||||
'common.name': 'Name',
|
||||
'common.name.required': 'Name is required',
|
||||
'common.note': 'Note',
|
||||
'common.image': 'Image',
|
||||
'common.type': 'Type',
|
||||
'common.type.placeholder': 'Select Type',
|
||||
'common.status': 'Status',
|
||||
'common.connect': 'Connection',
|
||||
'common.province': 'Province',
|
||||
'common.description': 'Description',
|
||||
'common.description.required': 'Description is required',
|
||||
@@ -48,6 +52,7 @@ export default {
|
||||
'common.updated_at': 'Updated At',
|
||||
'common.undefined': 'Undefined',
|
||||
'common.not_empty': 'Cannot be empty!',
|
||||
'common.level.disconnected': 'Disconnected',
|
||||
'common.level.normal': 'Normal',
|
||||
'common.level.warning': 'Warning',
|
||||
'common.level.critical': 'Critical',
|
||||
|
||||
@@ -21,6 +21,9 @@ export default {
|
||||
'master.devices.create.error': 'Device creation failed',
|
||||
'master.devices.groups': 'Groups',
|
||||
'master.devices.groups.required': 'Please select groups',
|
||||
// Update info device
|
||||
'master.devices.update.success': 'Updated successfully',
|
||||
'master.devices.update.error': 'Update failed',
|
||||
// Edit device modal
|
||||
'master.devices.update.title': 'Update device',
|
||||
'master.devices.ok': 'OK',
|
||||
@@ -32,4 +35,13 @@ export default {
|
||||
'master.devices.address': 'Address',
|
||||
'master.devices.address.placeholder': 'Enter address',
|
||||
'master.devices.address.required': 'Please enter address',
|
||||
// Location modal
|
||||
'master.devices.location.title': 'Update location',
|
||||
'master.devices.location.latitude': 'Latitude',
|
||||
'master.devices.location.latitude.required': 'Please enter latitude',
|
||||
'master.devices.location.longitude': 'Longitude',
|
||||
'master.devices.location.longitude.required': 'Please enter longitude',
|
||||
'master.devices.location.placeholder': 'Enter data',
|
||||
'master.devices.location.update.success': 'Location updated successfully',
|
||||
'master.devices.location.update.error': 'Location update failed',
|
||||
};
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
import sgwFish from './sgw-fish-en';
|
||||
import sgwMap from './sgw-map-en';
|
||||
import sgwMenu from './sgw-menu-en';
|
||||
import sgwPhoto from './sgw-photo-en';
|
||||
import sgwShip from './sgw-ship-en';
|
||||
import sgwTrip from './sgw-trip-en';
|
||||
import sgwZone from './sgw-zone-en';
|
||||
|
||||
export default {
|
||||
'sgw.title': 'Sea Gateway',
|
||||
'sgw.ship': 'Ship',
|
||||
...sgwMenu,
|
||||
...sgwTrip,
|
||||
...sgwMap,
|
||||
...sgwShip,
|
||||
...sgwFish,
|
||||
...sgwPhoto,
|
||||
...sgwZone,
|
||||
};
|
||||
|
||||
53
src/locales/en-US/slave/sgw/sgw-fish-en.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export default {
|
||||
// Fish group
|
||||
'fish.fish_group': 'Fish Group',
|
||||
'fish.fish_group.tooltip': 'Enter fish group name',
|
||||
'fish.fish_group.placeholder': 'Enter fish group',
|
||||
|
||||
// Rarity
|
||||
'fish.rarity': 'Rarity Level',
|
||||
'fish.rarity.placeholder': 'Select rarity level',
|
||||
'fish.rarity.normal': 'Normal',
|
||||
'fish.rarity.sensitive': 'Sensitive',
|
||||
'fish.rarity.near_threatened': 'Near Threatened',
|
||||
'fish.rarity.vulnerable': 'Vulnerable',
|
||||
'fish.rarity.endangered': 'Endangered',
|
||||
'fish.rarity.critically_endangered': 'Critically Endangered',
|
||||
'fish.rarity.extinct_in_the_wild': 'Extinct in the Wild',
|
||||
'fish.rarity.data_deficient': 'Data Deficient',
|
||||
|
||||
// Fish name
|
||||
'fish.name': 'Fish Name',
|
||||
'fish.name.tooltip': 'Enter fish name',
|
||||
'fish.name.placeholder': 'Enter fish name',
|
||||
'fish.name.required': 'Please enter fish name',
|
||||
|
||||
// Specific name
|
||||
'fish.specific_name': 'Scientific Name',
|
||||
'fish.specific_name.placeholder': 'Enter scientific name',
|
||||
|
||||
// Actions
|
||||
'fish.create.title': 'Add New Fish Species',
|
||||
'fish.edit.title': 'Edit Fish Species',
|
||||
'fish.delete.title': 'Delete Fish Species',
|
||||
'fish.delete.confirm': 'Are you sure you want to delete this fish species?',
|
||||
'fish.delete_confirm': 'Are you sure you want to delete this fish species?',
|
||||
'fish.delete.success': 'Fish species deleted successfully',
|
||||
'fish.delete.fail': 'Failed to delete fish species',
|
||||
'fish.create.success': 'Fish species created successfully',
|
||||
'fish.create.fail': 'Failed to create fish species',
|
||||
'fish.update.success': 'Fish species updated successfully',
|
||||
'fish.update.fail': 'Failed to update fish species',
|
||||
|
||||
// Table columns
|
||||
'fish.table.name': 'Fish Name',
|
||||
'fish.table.specific_name': 'Scientific Name',
|
||||
'fish.table.fish_group': 'Fish Group',
|
||||
'fish.table.rarity': 'Rarity Level',
|
||||
'fish.table.actions': 'Actions',
|
||||
|
||||
// Search & Filter
|
||||
'fish.search.placeholder': 'Search fish species...',
|
||||
'fish.filter.group': 'Filter by group',
|
||||
'fish.filter.rarity': 'Filter by rarity',
|
||||
};
|
||||
37
src/locales/en-US/slave/sgw/sgw-map-en.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export default {
|
||||
// Map
|
||||
'home.mapError': 'Map Error',
|
||||
'map.ship_detail.heading': 'Heading',
|
||||
'map.ship_detail.speed': 'Speed',
|
||||
'map.ship_detail.name': 'Ship Information',
|
||||
|
||||
// Thing status
|
||||
'thing.name': 'Device Name',
|
||||
'thing.status': 'Status',
|
||||
'thing.status.normal': 'Normal',
|
||||
'thing.status.warning': 'Warning',
|
||||
'thing.status.critical': 'Critical',
|
||||
'thing.status.sos': 'SOS',
|
||||
|
||||
// Map layers
|
||||
'map.layer.list': 'Map Layers',
|
||||
'map.layer.fishing_ban_zone': 'Fishing Ban Zone',
|
||||
'map.layer.entry_ban_zone': 'Entry Ban Zone',
|
||||
'map.layer.boundary_lines': 'Boundary Lines',
|
||||
'map.layer.ports': 'Ports',
|
||||
|
||||
// Map filters
|
||||
'map.filter.name': 'Filter',
|
||||
'map.filter.ship_name': 'Ship Name',
|
||||
'map.filter.ship_name_tooltip': 'Enter ship name to search',
|
||||
'map.filter.ship_reg_number': 'Registration Number',
|
||||
'map.filter.ship_reg_number_tooltip':
|
||||
'Enter ship registration number to search',
|
||||
'map.filter.ship_length': 'Ship Length (m)',
|
||||
'map.filter.ship_power': 'Power (HP)',
|
||||
'map.filter.ship_type': 'Ship Type',
|
||||
'map.filter.ship_type_placeholder': 'Select ship type',
|
||||
'map.filter.ship_warning': 'Warning',
|
||||
'map.filter.ship_warning_placeholder': 'Select warning type',
|
||||
'map.filter.area_type': 'Area Type',
|
||||
};
|
||||
@@ -1,6 +1,11 @@
|
||||
export default {
|
||||
'menu.sgw.map': 'Maps',
|
||||
'menu.sgw.trips': 'Trips',
|
||||
'menu.manager.sgw.fishes': 'Fishes',
|
||||
'menu.manager.sgw.zones': 'Zones',
|
||||
'menu.sgw.ships': 'Ships',
|
||||
'menu.sgw.fishes': 'Fishes',
|
||||
'menu.sgw.zones': 'Prohibited Zones',
|
||||
|
||||
// Manager menu
|
||||
'menu.manager.sgw.fishes': 'Fish Management',
|
||||
'menu.manager.sgw.zones': 'Zone Management',
|
||||
};
|
||||
|
||||
16
src/locales/en-US/slave/sgw/sgw-photo-en.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
'photo.main': 'Main Photo',
|
||||
'photo.sub': 'Sub Photos',
|
||||
'photo.upload': 'Upload Photo',
|
||||
'photo.delete': 'Delete Photo',
|
||||
'photo.delete.confirm': 'Are you sure you want to delete this photo?',
|
||||
'photo.delete.success': 'Photo deleted successfully',
|
||||
'photo.delete.fail': 'Failed to delete photo',
|
||||
'photo.upload.success': 'Photo uploaded successfully',
|
||||
'photo.upload.fail': 'Failed to upload photo',
|
||||
'photo.upload.limit': 'Photo size must not exceed 5MB',
|
||||
'photo.upload.format': 'Only JPG/PNG format supported',
|
||||
'photo.manage': 'Manage Photos',
|
||||
'photo.change': 'Change Photo',
|
||||
'photo.add': 'Add Photo',
|
||||
};
|
||||
16
src/locales/en-US/slave/sgw/sgw-ship-en.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
// Pages - Ship List
|
||||
'pages.ships.reg_number': 'Registration Number',
|
||||
'pages.ships.name': 'Ship Name',
|
||||
'pages.ships.type': 'Ship Type',
|
||||
'pages.ships.home_port': 'Home Port',
|
||||
'pages.ships.option': 'Options',
|
||||
|
||||
// Pages - Ship Create/Edit
|
||||
'pages.ship.create.text': 'Create New Ship',
|
||||
'pages.ships.create.title': 'Add New Ship',
|
||||
'pages.ships.edit.title': 'Edit Ship',
|
||||
|
||||
// Pages - Things (Ship related)
|
||||
'pages.things.fishing_license_expiry_date': 'Fishing License Expiry Date',
|
||||
};
|
||||
64
src/locales/en-US/slave/sgw/sgw-trip-en.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export default {
|
||||
// Pages - Trip List
|
||||
'pages.trips.name': 'Trip Name',
|
||||
'pages.trips.ship_id': 'Ship',
|
||||
'pages.trips.departure_time': 'Departure Time',
|
||||
'pages.trips.arrival_time': 'Arrival Time',
|
||||
'pages.trips.status': 'Status',
|
||||
'pages.trips.status.created': 'Created',
|
||||
'pages.trips.status.pending_approval': 'Pending Approval',
|
||||
'pages.trips.status.approved': 'Approved',
|
||||
'pages.trips.status.active': 'Active',
|
||||
'pages.trips.status.completed': 'Completed',
|
||||
'pages.trips.status.cancelled': 'Cancelled',
|
||||
|
||||
// Pages - Date filters
|
||||
'pages.date.yesterday': 'Yesterday',
|
||||
'pages.date.lastweek': 'Last Week',
|
||||
'pages.date.lastmonth': 'Last Month',
|
||||
|
||||
// Pages - Things/Ship
|
||||
'pages.things.createTrip.text': 'Create Trip',
|
||||
'pages.things.option': 'Options',
|
||||
|
||||
// Trip badges
|
||||
'trip.badge.active': 'Active',
|
||||
'trip.badge.approved': 'Approved',
|
||||
'trip.badge.cancelled': 'Cancelled',
|
||||
'trip.badge.completed': 'Completed',
|
||||
'trip.badge.notApproved': 'Not Approved',
|
||||
'trip.badge.unknown': 'Unknown',
|
||||
'trip.badge.waitingApproval': 'Waiting Approval',
|
||||
|
||||
// Cancel trip
|
||||
'trip.cancelTrip.button': 'Cancel Trip',
|
||||
'trip.cancelTrip.placeholder': 'Enter reason for cancellation',
|
||||
'trip.cancelTrip.reason': 'Reason',
|
||||
'trip.cancelTrip.title': 'Cancel Trip',
|
||||
'trip.cancelTrip.validation': 'Please enter a reason',
|
||||
|
||||
// Trip cost
|
||||
'trip.cost.amount': 'Amount',
|
||||
'trip.cost.crewSalary': 'Crew Salary',
|
||||
'trip.cost.food': 'Food',
|
||||
'trip.cost.fuel': 'Fuel',
|
||||
'trip.cost.grandTotal': 'Grand Total',
|
||||
'trip.cost.iceSalt': 'Ice & Salt',
|
||||
'trip.cost.price': 'Price',
|
||||
'trip.cost.total': 'Total',
|
||||
'trip.cost.type': 'Type',
|
||||
'trip.cost.unit': 'Unit',
|
||||
|
||||
// Trip gear
|
||||
'trip.gear.name': 'Gear Name',
|
||||
'trip.gear.quantity': 'Quantity',
|
||||
|
||||
// Haul fish list
|
||||
'trip.haulFishList.fishCondition': 'Condition',
|
||||
'trip.haulFishList.fishName': 'Fish Name',
|
||||
'trip.haulFishList.fishRarity': 'Rarity',
|
||||
'trip.haulFishList.fishSize': 'Size',
|
||||
'trip.haulFishList.gearUsage': 'Gear Usage',
|
||||
'trip.haulFishList.title': 'Haul Fish List',
|
||||
'trip.haulFishList.weight': 'Weight',
|
||||
};
|
||||
32
src/locales/en-US/slave/sgw/sgw-zone-en.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export default {
|
||||
// Table columns
|
||||
'banzones.name': 'Zone Name',
|
||||
'banzones.area': 'Province/City',
|
||||
'banzones.description': 'Description',
|
||||
'banzones.type': 'Type',
|
||||
'banzones.conditions': 'Conditions',
|
||||
'banzones.state': 'Status',
|
||||
'banzones.action': 'Actions',
|
||||
'banzones.title': 'zones',
|
||||
'banzones.create': 'Create Zone',
|
||||
|
||||
// Zone types
|
||||
'banzone.area.fishing_ban': 'Fishing Ban',
|
||||
'banzone.area.move_ban': 'Movement Ban',
|
||||
'banzone.area.safe': 'Safe Area',
|
||||
|
||||
// Status
|
||||
'banzone.is_enable': 'Enabled',
|
||||
'banzone.is_unenabled': 'Disabled',
|
||||
|
||||
// Shape types
|
||||
'banzone.polygon': 'Polygon',
|
||||
'banzone.polyline': 'Polyline',
|
||||
'banzone.circle': 'Circle',
|
||||
|
||||
// Notifications
|
||||
'banzone.notify.delete_zone_success': 'Zone deleted successfully',
|
||||
'banzone.notify.delete_zone_confirm':
|
||||
'Are you sure you want to delete this zone',
|
||||
'banzone.notify.fail': 'Operation failed!',
|
||||
};
|
||||
@@ -32,6 +32,7 @@ export default {
|
||||
'common.theme.dark': 'Tối',
|
||||
'common.paginations.things': 'thiết bị',
|
||||
'common.paginations.of': 'trên',
|
||||
'common.of': 'trên',
|
||||
'common.name': 'Tên',
|
||||
'common.name.required': 'Tên không được để trống',
|
||||
'common.note': 'Ghi chú',
|
||||
@@ -39,6 +40,7 @@ export default {
|
||||
'common.type': 'Loại',
|
||||
'common.type.placeholder': 'Chọn loại',
|
||||
'common.status': 'Trạng thái',
|
||||
'common.connect': 'Kết nối',
|
||||
'common.province': 'Tỉnh',
|
||||
'common.description': 'Mô tả',
|
||||
'common.description.required': 'Mô tả không được để trống',
|
||||
@@ -49,6 +51,7 @@ export default {
|
||||
'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.disconnected': 'Mất kết nối',
|
||||
'common.level.normal': 'Bình thường',
|
||||
'common.level.warning': 'Cảnh báo',
|
||||
'common.level.critical': 'Nguy hiểm',
|
||||
|
||||
@@ -3,6 +3,7 @@ export default {
|
||||
'master.thing.external_id': 'External ID',
|
||||
'master.thing.group': 'Nhóm',
|
||||
'master.thing.address': 'Địa chỉ',
|
||||
|
||||
// Device translations
|
||||
'master.devices.title': 'Quản lý thiết bị',
|
||||
'master.devices.name': 'Tên',
|
||||
@@ -20,6 +21,9 @@ export default {
|
||||
'master.devices.create.error': 'Tạo thiết bị lỗi',
|
||||
'master.devices.groups': 'Đơn vị',
|
||||
'master.devices.groups.required': 'Vui lòng chọn đơn vị',
|
||||
// Update info device
|
||||
'master.devices.update.success': 'Cập nhật thành công',
|
||||
'master.devices.update.error': 'Cập nhật thất bại',
|
||||
// Edit device modal
|
||||
'master.devices.update.title': 'Cập nhật thiết bị',
|
||||
'master.devices.ok': 'Đồng ý',
|
||||
@@ -31,4 +35,13 @@ export default {
|
||||
'master.devices.address': 'Địa chỉ',
|
||||
'master.devices.address.placeholder': 'Nhập địa chỉ',
|
||||
'master.devices.address.required': 'Vui lòng nhập địa chỉ',
|
||||
// Location modal
|
||||
'master.devices.location.title': 'Cập nhật vị trí',
|
||||
'master.devices.location.latitude': 'Vị độ',
|
||||
'master.devices.location.latitude.required': 'Vui lòng nhập vị độ',
|
||||
'master.devices.location.longitude': 'Kinh độ',
|
||||
'master.devices.location.longitude.required': 'Vui lòng nhập kinh độ',
|
||||
'master.devices.location.placeholder': 'Nhập dữ liệu',
|
||||
'master.devices.location.update.success': 'Cập nhật vị trí thành công',
|
||||
'master.devices.location.update.error': 'Cập nhật vị trí thất bại',
|
||||
};
|
||||
|
||||
53
src/locales/vi-VN/slave/sgw/sgw-fish-vi.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
export default {
|
||||
// Fish group
|
||||
'fish.fish_group': 'Nhóm loài cá',
|
||||
'fish.fish_group.tooltip': 'Nhập tên nhóm loài cá',
|
||||
'fish.fish_group.placeholder': 'Nhập nhóm loài cá',
|
||||
|
||||
// Rarity
|
||||
'fish.rarity': 'Mức độ quý hiếm',
|
||||
'fish.rarity.placeholder': 'Chọn mức độ quý hiếm',
|
||||
'fish.rarity.normal': 'Bình thường',
|
||||
'fish.rarity.sensitive': 'Nhạy cảm',
|
||||
'fish.rarity.near_threatened': 'Gần nguy cấp',
|
||||
'fish.rarity.vulnerable': 'Sắp nguy cấp',
|
||||
'fish.rarity.endangered': 'Nguy cấp',
|
||||
'fish.rarity.critically_endangered': 'Cực kỳ nguy cấp',
|
||||
'fish.rarity.extinct_in_the_wild': 'Tuyệt chủng trong tự nhiên',
|
||||
'fish.rarity.data_deficient': 'Thiếu dữ liệu',
|
||||
|
||||
// Fish name
|
||||
'fish.name': 'Tên loài cá',
|
||||
'fish.name.tooltip': 'Nhập tên loài cá',
|
||||
'fish.name.placeholder': 'Nhập tên loài cá',
|
||||
'fish.name.required': 'Vui lòng nhập tên loài cá',
|
||||
|
||||
// Specific name
|
||||
'fish.specific_name': 'Tên khoa học',
|
||||
'fish.specific_name.placeholder': 'Nhập tên khoa học',
|
||||
|
||||
// Actions
|
||||
'fish.create.title': 'Thêm loài cá mới',
|
||||
'fish.edit.title': 'Chỉnh sửa loài cá',
|
||||
'fish.delete.title': 'Xóa loài cá',
|
||||
'fish.delete.confirm': 'Bạn có chắc chắn muốn xóa loài cá này không?',
|
||||
'fish.delete_confirm': 'Bạn có chắc chắn muốn xóa loài cá này không?',
|
||||
'fish.delete.success': 'Xóa loài cá thành công',
|
||||
'fish.delete.fail': 'Xóa loài cá thất bại',
|
||||
'fish.create.success': 'Thêm loài cá thành công',
|
||||
'fish.create.fail': 'Thêm loài cá thất bại',
|
||||
'fish.update.success': 'Cập nhật loài cá thành công',
|
||||
'fish.update.fail': 'Cập nhật loài cá thất bại',
|
||||
|
||||
// Table columns
|
||||
'fish.table.name': 'Tên loài cá',
|
||||
'fish.table.specific_name': 'Tên khoa học',
|
||||
'fish.table.fish_group': 'Nhóm loài',
|
||||
'fish.table.rarity': 'Mức độ quý hiếm',
|
||||
'fish.table.actions': 'Hành động',
|
||||
|
||||
// Search & Filter
|
||||
'fish.search.placeholder': 'Tìm kiếm loài cá...',
|
||||
'fish.filter.group': 'Lọc theo nhóm',
|
||||
'fish.filter.rarity': 'Lọc theo mức độ quý hiếm',
|
||||
};
|
||||
36
src/locales/vi-VN/slave/sgw/sgw-map-vi.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export default {
|
||||
// Map
|
||||
'home.mapError': 'Lỗi bản đồ',
|
||||
'map.ship_detail.heading': 'Hướng di chuyển',
|
||||
'map.ship_detail.speed': 'Tốc độ',
|
||||
'map.ship_detail.name': 'Thông tin tàu',
|
||||
|
||||
// Thing status
|
||||
'thing.name': 'Tên thiết bị',
|
||||
'thing.status': 'Trạng thái',
|
||||
'thing.status.normal': 'Bình thường',
|
||||
'thing.status.warning': 'Cảnh báo',
|
||||
'thing.status.critical': 'Nghiêm trọng',
|
||||
'thing.status.sos': 'Khẩn cấp',
|
||||
|
||||
// Map layers
|
||||
'map.layer.list': 'Danh sách lớp bản đồ',
|
||||
'map.layer.fishing_ban_zone': 'Khu vực cấm đánh bắt',
|
||||
'map.layer.entry_ban_zone': 'Khu vực cấm vào',
|
||||
'map.layer.boundary_lines': 'Đường biên giới',
|
||||
'map.layer.ports': 'Cảng',
|
||||
|
||||
// Map filters
|
||||
'map.filter.name': 'Bộ lọc',
|
||||
'map.filter.ship_name': 'Tên tàu',
|
||||
'map.filter.ship_name_tooltip': 'Nhập tên tàu để tìm kiếm',
|
||||
'map.filter.ship_reg_number': 'Số đăng ký',
|
||||
'map.filter.ship_reg_number_tooltip': 'Nhập số đăng ký tàu để tìm kiếm',
|
||||
'map.filter.ship_length': 'Chiều dài tàu (m)',
|
||||
'map.filter.ship_power': 'Công suất (HP)',
|
||||
'map.filter.ship_type': 'Loại tàu',
|
||||
'map.filter.ship_type_placeholder': 'Chọn loại tàu',
|
||||
'map.filter.ship_warning': 'Cảnh báo',
|
||||
'map.filter.ship_warning_placeholder': 'Chọn loại cảnh báo',
|
||||
'map.filter.area_type': 'Loại khu vực',
|
||||
};
|
||||
@@ -1,6 +1,11 @@
|
||||
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',
|
||||
'menu.sgw.ships': 'Quản lý tàu',
|
||||
'menu.sgw.fishes': 'Loài cá',
|
||||
'menu.sgw.zones': 'Khu vực cấm',
|
||||
|
||||
// Manager menu
|
||||
'menu.manager.sgw.fishes': 'Quản lý loài cá',
|
||||
'menu.manager.sgw.zones': 'Quản lý khu vực cấm',
|
||||
};
|
||||
|
||||
16
src/locales/vi-VN/slave/sgw/sgw-photo-vi.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
'photo.main': 'Ảnh chính',
|
||||
'photo.sub': 'Ảnh phụ',
|
||||
'photo.upload': 'Tải ảnh lên',
|
||||
'photo.delete': 'Xóa ảnh',
|
||||
'photo.delete.confirm': 'Bạn có chắc chắn muốn xóa ảnh này không?',
|
||||
'photo.delete.success': 'Xóa ảnh thành công',
|
||||
'photo.delete.fail': 'Xóa ảnh thất bại',
|
||||
'photo.upload.success': 'Tải ảnh lên thành công',
|
||||
'photo.upload.fail': 'Tải ảnh lên thất bại',
|
||||
'photo.upload.limit': 'Kích thước ảnh không được vượt quá 5MB',
|
||||
'photo.upload.format': 'Chỉ hỗ trợ định dạng JPG/PNG',
|
||||
'photo.manage': 'Quản lý ảnh',
|
||||
'photo.change': 'Thay đổi ảnh',
|
||||
'photo.add': 'Thêm ảnh',
|
||||
};
|
||||
16
src/locales/vi-VN/slave/sgw/sgw-ship-vi.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export default {
|
||||
// Pages - Ship List
|
||||
'pages.ships.reg_number': 'Số đăng ký',
|
||||
'pages.ships.name': 'Tên tàu',
|
||||
'pages.ships.type': 'Loại tàu',
|
||||
'pages.ships.home_port': 'Cảng đăng ký',
|
||||
'pages.ships.option': 'Tùy chọn',
|
||||
|
||||
// Pages - Ship Create/Edit
|
||||
'pages.ship.create.text': 'Tạo tàu mới',
|
||||
'pages.ships.create.title': 'Thêm tàu mới',
|
||||
'pages.ships.edit.title': 'Chỉnh sửa tàu',
|
||||
|
||||
// Pages - Things (Ship related)
|
||||
'pages.things.fishing_license_expiry_date': 'Ngày hết hạn giấy phép',
|
||||
};
|
||||
64
src/locales/vi-VN/slave/sgw/sgw-trip-vi.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export default {
|
||||
// Pages - Trip List
|
||||
'pages.trips.name': 'Tên chuyến đi',
|
||||
'pages.trips.ship_id': 'Tàu',
|
||||
'pages.trips.departure_time': 'Thời gian khởi hành',
|
||||
'pages.trips.arrival_time': 'Thời gian về',
|
||||
'pages.trips.status': 'Trạng thái',
|
||||
'pages.trips.status.created': 'Đã tạo',
|
||||
'pages.trips.status.pending_approval': 'Chờ phê duyệt',
|
||||
'pages.trips.status.approved': 'Đã phê duyệt',
|
||||
'pages.trips.status.active': 'Đang hoạt động',
|
||||
'pages.trips.status.completed': 'Hoàn thành',
|
||||
'pages.trips.status.cancelled': 'Đã hủy',
|
||||
|
||||
// Pages - Date filters
|
||||
'pages.date.yesterday': 'Hôm qua',
|
||||
'pages.date.lastweek': 'Tuần trước',
|
||||
'pages.date.lastmonth': 'Tháng trước',
|
||||
|
||||
// Pages - Things/Ship
|
||||
'pages.things.createTrip.text': 'Tạo chuyến đi',
|
||||
'pages.things.option': 'Tùy chọn',
|
||||
|
||||
// Trip badges
|
||||
'trip.badge.active': 'Đang hoạt động',
|
||||
'trip.badge.approved': 'Đã duyệt',
|
||||
'trip.badge.cancelled': 'Đã hủy',
|
||||
'trip.badge.completed': 'Hoàn thành',
|
||||
'trip.badge.notApproved': 'Chưa duyệt',
|
||||
'trip.badge.unknown': 'Không xác định',
|
||||
'trip.badge.waitingApproval': 'Chờ duyệt',
|
||||
|
||||
// Cancel trip
|
||||
'trip.cancelTrip.button': 'Hủy chuyến',
|
||||
'trip.cancelTrip.placeholder': 'Nhập lý do hủy chuyến',
|
||||
'trip.cancelTrip.reason': 'Lý do',
|
||||
'trip.cancelTrip.title': 'Hủy chuyến đi',
|
||||
'trip.cancelTrip.validation': 'Vui lòng nhập lý do',
|
||||
|
||||
// Trip cost
|
||||
'trip.cost.amount': 'Số lượng',
|
||||
'trip.cost.crewSalary': 'Lương thuyền viên',
|
||||
'trip.cost.food': 'Thực phẩm',
|
||||
'trip.cost.fuel': 'Nhiên liệu',
|
||||
'trip.cost.grandTotal': 'Tổng cộng',
|
||||
'trip.cost.iceSalt': 'Đá & Muối',
|
||||
'trip.cost.price': 'Giá',
|
||||
'trip.cost.total': 'Tổng',
|
||||
'trip.cost.type': 'Loại',
|
||||
'trip.cost.unit': 'Đơn vị',
|
||||
|
||||
// Trip gear
|
||||
'trip.gear.name': 'Tên ngư cụ',
|
||||
'trip.gear.quantity': 'Số lượng',
|
||||
|
||||
// Haul fish list
|
||||
'trip.haulFishList.fishCondition': 'Tình trạng',
|
||||
'trip.haulFishList.fishName': 'Tên cá',
|
||||
'trip.haulFishList.fishRarity': 'Độ hiếm',
|
||||
'trip.haulFishList.fishSize': 'Kích thước',
|
||||
'trip.haulFishList.gearUsage': 'Ngư cụ sử dụng',
|
||||
'trip.haulFishList.title': 'Danh sách đánh bắt',
|
||||
'trip.haulFishList.weight': 'Trọng lượng',
|
||||
};
|
||||
@@ -1,6 +1,19 @@
|
||||
import sgwFish from './sgw-fish-vi';
|
||||
import sgwMap from './sgw-map-vi';
|
||||
import sgwMenu from './sgw-menu-vi';
|
||||
import sgwPhoto from './sgw-photo-vi';
|
||||
import sgwShip from './sgw-ship-vi';
|
||||
import sgwTrip from './sgw-trip-vi';
|
||||
import sgwZone from './sgw-zone-vi';
|
||||
|
||||
export default {
|
||||
'sgw.title': 'Hệ thống giám sát tàu cá',
|
||||
'sgw.ship': 'Tàu',
|
||||
...sgwMenu,
|
||||
...sgwTrip,
|
||||
...sgwMap,
|
||||
...sgwShip,
|
||||
...sgwFish,
|
||||
...sgwPhoto,
|
||||
...sgwZone,
|
||||
};
|
||||
|
||||
31
src/locales/vi-VN/slave/sgw/sgw-zone-vi.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export default {
|
||||
// Table columns
|
||||
'banzones.name': 'Tên khu vực',
|
||||
'banzones.area': 'Tỉnh/Thành phố',
|
||||
'banzones.description': 'Mô tả',
|
||||
'banzones.type': 'Loại',
|
||||
'banzones.conditions': 'Điều kiện',
|
||||
'banzones.state': 'Trạng thái',
|
||||
'banzones.action': 'Hành động',
|
||||
'banzones.title': 'khu vực',
|
||||
'banzones.create': 'Tạo khu vực',
|
||||
|
||||
// Zone types
|
||||
'banzone.area.fishing_ban': 'Cấm khai thác',
|
||||
'banzone.area.move_ban': 'Cấm di chuyển',
|
||||
'banzone.area.safe': 'Khu vực an toàn',
|
||||
|
||||
// Status
|
||||
'banzone.is_enable': 'Kích hoạt',
|
||||
'banzone.is_unenabled': 'Vô hiệu hóa',
|
||||
|
||||
// Shape types
|
||||
'banzone.polygon': 'Đa giác',
|
||||
'banzone.polyline': 'Đường kẻ',
|
||||
'banzone.circle': 'Hình tròn',
|
||||
|
||||
// Notifications
|
||||
'banzone.notify.delete_zone_success': 'Xóa khu vực thành công',
|
||||
'banzone.notify.delete_zone_confirm': 'Bạn có chắc chắn muốn xóa khu vực',
|
||||
'banzone.notify.fail': 'Thao tác thất bại!',
|
||||
};
|
||||
36
src/models/slave/sgw/useHomePorts.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { apiQueryPorts } from '@/services/slave/sgw/ShipController';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export default function useHomeport() {
|
||||
const [homeports, setHomeports] = useState<SgwModel.Port[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const getHomeportsByProvinceCode = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: SgwModel.PortQueryParams = {
|
||||
name: '',
|
||||
order: 'name',
|
||||
dir: 'asc',
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
};
|
||||
|
||||
console.log('Calling apiQueryPorts with params:', params);
|
||||
const res = await apiQueryPorts(params);
|
||||
console.log('apiQueryPorts response:', res);
|
||||
setHomeports(res?.ports || []);
|
||||
} catch (err) {
|
||||
console.error('Fetch Homeports failed:', err);
|
||||
setHomeports([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
homeports,
|
||||
loading,
|
||||
getHomeportsByProvinceCode,
|
||||
};
|
||||
}
|
||||
30
src/models/slave/sgw/useShipSos.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { SHIP_SOS_WS_URL } from '@/constants/slave/sgw/websocket';
|
||||
import { wsClient } from '@/utils/wsClient';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export default function useGetShipSos() {
|
||||
const [shipSos, setShipSos] = useState<WsTypes.WsThingResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const getShipSosWs = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
wsClient.connect(SHIP_SOS_WS_URL, true);
|
||||
const unsubscribe = wsClient.subscribe(
|
||||
(data: WsTypes.WsThingResponse) => {
|
||||
setShipSos((pre) => {
|
||||
if (pre?.time && data.time && pre.time > data.time) {
|
||||
return pre;
|
||||
}
|
||||
return data;
|
||||
});
|
||||
setLoading(false);
|
||||
},
|
||||
);
|
||||
return unsubscribe;
|
||||
} catch (error) {
|
||||
console.error('Error when get Ship SOS: ', error);
|
||||
}
|
||||
}, []);
|
||||
return { shipSos, getShipSosWs, loading };
|
||||
}
|
||||
25
src/models/slave/sgw/useShipTypes.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { apiGetShipTypes } from '@/services/slave/sgw/ShipController';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export default function useShipTypes() {
|
||||
const [shipTypes, setShipTypes] = useState<SgwModel.ShipType[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const getShipTypes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiGetShipTypes(); // đổi URL cho phù hợp
|
||||
setShipTypes(res || null);
|
||||
} catch (err) {
|
||||
console.error('Fetch ShipTypes failed', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
shipTypes,
|
||||
loading,
|
||||
getShipTypes,
|
||||
};
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
FilterOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
|
||||
import { Button, Flex, message, Popconfirm, Progress, Tooltip } from 'antd';
|
||||
import moment from 'moment';
|
||||
import { useRef, useState } from 'react';
|
||||
@@ -24,6 +24,9 @@ const AlarmPage = () => {
|
||||
const [thingFilterDatas, setThingFilterDatas] = useState<string[]>([]);
|
||||
const intl = useIntl();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const { currentUserProfile } = initialState || {};
|
||||
|
||||
const columns: ProColumns<MasterModel.Alarm>[] = [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
|
||||
505
src/pages/Manager/Device/Camera/index.tsx
Normal file
@@ -0,0 +1,505 @@
|
||||
import { apiSearchThings } from '@/services/master/ThingController';
|
||||
import { wsClient } from '@/utils/wsClient';
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SettingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { PageContainer } from '@ant-design/pro-components';
|
||||
import { history, useParams } from '@umijs/max';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Col,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
theme,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// Camera types
|
||||
const CAMERA_TYPES = [
|
||||
{ label: 'HIKVISION', value: 'HIKVISION' },
|
||||
{ label: 'DAHUA', value: 'DAHUA' },
|
||||
{ label: 'GENERIC', value: 'GENERIC' },
|
||||
];
|
||||
|
||||
// Recording modes
|
||||
const RECORDING_MODES = [
|
||||
{ label: 'Theo cảnh báo', value: 'alarm' },
|
||||
{ label: 'Liên tục', value: 'continuous' },
|
||||
{ label: 'Thủ công', value: 'manual' },
|
||||
];
|
||||
|
||||
// Alert types for configuration
|
||||
const ALERT_TYPES = [
|
||||
{ id: 'motion', name: 'Chuyển Động có cảnh báo' },
|
||||
{ id: 'smoke', name: 'Khói có cảnh báo' },
|
||||
{ id: 'door', name: 'Cửa có cảnh báo' },
|
||||
{ id: 'ac1_high', name: 'Điện AC 1 cao' },
|
||||
{ id: 'ac1_low', name: 'Điện AC 1 thấp' },
|
||||
{ id: 'ac1_lost', name: 'Điện AC 1 mất' },
|
||||
{ id: 'load_high', name: 'Điện tải cao' },
|
||||
{ id: 'load_low', name: 'Điện tải thấp' },
|
||||
{ id: 'load_lost', name: 'Điện tải mất' },
|
||||
{ id: 'grid_high', name: 'Điện lưới cao' },
|
||||
{ id: 'grid_low', name: 'Điện lưới thấp' },
|
||||
{ id: 'grid_lost', name: 'Điện lưới mất' },
|
||||
{ id: 'ac1_on_error', name: 'Điều hòa 1 bật lỗi' },
|
||||
{ id: 'ac1_off_error', name: 'Điều hòa 1 tắt lỗi' },
|
||||
{ id: 'ac1_has_error', name: 'Điều hòa 1 có thể lỗi' },
|
||||
{ id: 'ac2_on_error', name: 'Điều hòa 2 bật lỗi' },
|
||||
{ id: 'ac2_off_error', name: 'Điều hòa 2 tắt lỗi' },
|
||||
{ id: 'ac2_has_error', name: 'Điều hòa 2 điều hòa có thể lỗi' },
|
||||
{ id: 'room_temp_high', name: 'Nhiệt độ phòng máy nhiệt độ phòng máy cao' },
|
||||
{ id: 'rectifier_error', name: 'Rectifier bật lỗi' },
|
||||
{ id: 'meter_volt_high', name: 'Công tơ điện điện áp cao' },
|
||||
{ id: 'meter_volt_low', name: 'Công tơ điện điện áp thấp' },
|
||||
{ id: 'meter_lost', name: 'Công tơ điện mất điện áp' },
|
||||
{ id: 'lithium_volt_low', name: 'Pin lithium điện áp thấp' },
|
||||
{ id: 'lithium_temp_high', name: 'Pin lithium nhiệt độ cao' },
|
||||
{ id: 'lithium_capacity_low', name: 'Pin lithium dung lượng thấp' },
|
||||
];
|
||||
|
||||
// Camera interface
|
||||
interface Camera {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
ipAddress: string;
|
||||
}
|
||||
|
||||
interface CameraFormValues {
|
||||
name: string;
|
||||
type: string;
|
||||
account: string;
|
||||
password: string;
|
||||
ipAddress: string;
|
||||
rtspPort: number;
|
||||
httpPort: number;
|
||||
stream: number;
|
||||
channel: number;
|
||||
}
|
||||
|
||||
const CameraConfigPage = () => {
|
||||
const { thingId } = useParams<{ thingId: string }>();
|
||||
const { token } = theme.useToken();
|
||||
const [form] = Form.useForm<CameraFormValues>();
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [cameras, setCameras] = useState<Camera[]>([]);
|
||||
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([
|
||||
'motion',
|
||||
'smoke',
|
||||
'door',
|
||||
]);
|
||||
const [recordingMode, setRecordingMode] = useState('alarm');
|
||||
const [thingName, setThingName] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
wsClient.connect('wss://gms.smatec.com.vn/mqtt', false);
|
||||
const unsubscribe = wsClient.subscribe((data: any) => {
|
||||
console.log('Received WS data:', data);
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch thing info on mount
|
||||
useEffect(() => {
|
||||
const fetchThingInfo = async () => {
|
||||
if (!thingId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiSearchThings({
|
||||
offset: 0,
|
||||
limit: 1,
|
||||
id: thingId,
|
||||
});
|
||||
if (response?.things && response.things.length > 0) {
|
||||
setThingName(response.things[0].name || thingId);
|
||||
} else {
|
||||
setThingName(thingId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch thing info:', error);
|
||||
setThingName(thingId);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchThingInfo();
|
||||
}, [thingId]);
|
||||
|
||||
const handleBack = () => {
|
||||
history.push('/manager/devices');
|
||||
};
|
||||
|
||||
const handleOpenModal = () => {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
type: 'HIKVISION',
|
||||
rtspPort: 554,
|
||||
httpPort: 80,
|
||||
stream: 0,
|
||||
channel: 0,
|
||||
});
|
||||
setIsModalVisible(true);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleSubmitCamera = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
console.log('Camera values:', values);
|
||||
// TODO: Call API to create camera
|
||||
setCameras([
|
||||
...cameras,
|
||||
{
|
||||
id: String(cameras.length + 1),
|
||||
name: values.name,
|
||||
type: values.type,
|
||||
ipAddress: values.ipAddress,
|
||||
},
|
||||
]);
|
||||
handleCloseModal();
|
||||
} catch (error) {
|
||||
console.error('Validation failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAlertToggle = (alertId: string) => {
|
||||
if (selectedAlerts.includes(alertId)) {
|
||||
setSelectedAlerts(selectedAlerts.filter((id) => id !== alertId));
|
||||
} else {
|
||||
setSelectedAlerts([...selectedAlerts, alertId]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearAlerts = () => {
|
||||
setSelectedAlerts([]);
|
||||
};
|
||||
|
||||
const handleSubmitAlerts = () => {
|
||||
console.log('Submit alerts:', selectedAlerts);
|
||||
// TODO: Call API to save alert configuration
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'checkbox',
|
||||
width: 50,
|
||||
render: () => <Checkbox />,
|
||||
},
|
||||
{
|
||||
title: 'Tên',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text: string) => (
|
||||
<a style={{ color: token.colorPrimary }}>{text}</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Loại',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
},
|
||||
{
|
||||
title: 'Địa chỉ IP',
|
||||
dataIndex: 'ipAddress',
|
||||
key: 'ipAddress',
|
||||
},
|
||||
{
|
||||
title: 'Thao tác',
|
||||
key: 'action',
|
||||
render: () => <Button size="small" icon={<EditOutlined />} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Spin spinning={loading}>
|
||||
<PageContainer
|
||||
header={{
|
||||
title: (
|
||||
<Space>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ArrowLeftOutlined />}
|
||||
onClick={handleBack}
|
||||
/>
|
||||
<span>{thingName || 'Loading...'}</span>
|
||||
</Space>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Row gutter={24}>
|
||||
{/* Left Column - Camera Table */}
|
||||
<Col xs={24} md={10} lg={8}>
|
||||
<Card bodyStyle={{ padding: 16 }}>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleOpenModal}
|
||||
>
|
||||
Tạo mới camera
|
||||
</Button>
|
||||
<Button icon={<ReloadOutlined />} />
|
||||
<Button icon={<SettingOutlined />} />
|
||||
<Button icon={<DeleteOutlined />} />
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
dataSource={cameras}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={{
|
||||
size: 'small',
|
||||
showTotal: (total, range) =>
|
||||
`${range[0]}-${range[1]} trên ${total} mặt hàng`,
|
||||
pageSize: 10,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
{/* Right Column - Alert Configuration */}
|
||||
<Col xs={24} md={14} lg={16}>
|
||||
<Card bodyStyle={{ padding: 16 }}>
|
||||
{/* Recording Mode */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
Ghi dữ liệu camera
|
||||
</Text>
|
||||
<Select
|
||||
value={recordingMode}
|
||||
onChange={setRecordingMode}
|
||||
options={RECORDING_MODES}
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Alert List */}
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||
Danh sách cảnh báo
|
||||
</Text>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
padding: '8px 12px',
|
||||
background: token.colorBgContainer,
|
||||
borderRadius: token.borderRadius,
|
||||
border: `1px solid ${token.colorBorder}`,
|
||||
}}
|
||||
>
|
||||
<Text type="secondary">
|
||||
đã chọn {selectedAlerts.length} mục
|
||||
</Text>
|
||||
<Button type="link" onClick={handleClearAlerts}>
|
||||
Xóa
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Alert Cards Grid */}
|
||||
<Row gutter={[12, 12]}>
|
||||
{ALERT_TYPES.map((alert) => {
|
||||
const isSelected = selectedAlerts.includes(alert.id);
|
||||
return (
|
||||
<Col xs={12} sm={8} md={6} lg={4} xl={4} key={alert.id}>
|
||||
<Card
|
||||
size="small"
|
||||
hoverable
|
||||
onClick={() => handleAlertToggle(alert.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: isSelected
|
||||
? token.colorPrimary
|
||||
: token.colorBorder,
|
||||
borderWidth: isSelected ? 2 : 1,
|
||||
background: isSelected
|
||||
? token.colorPrimaryBg
|
||||
: token.colorBgContainer,
|
||||
height: 80,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
bodyStyle={{
|
||||
padding: 8,
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: isSelected
|
||||
? token.colorPrimary
|
||||
: token.colorText,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{alert.name}
|
||||
</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div style={{ marginTop: 24, textAlign: 'center' }}>
|
||||
<Button type="primary" onClick={handleSubmitAlerts}>
|
||||
Gửi đi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Create Camera Modal */}
|
||||
<Modal
|
||||
title="Tạo mới"
|
||||
open={isModalVisible}
|
||||
onCancel={handleCloseModal}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={handleCloseModal}>
|
||||
Hủy
|
||||
</Button>,
|
||||
<Button key="submit" type="primary" onClick={handleSubmitCamera}>
|
||||
Đồng ý
|
||||
</Button>,
|
||||
]}
|
||||
width={500}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
type: 'HIKVISION',
|
||||
rtspPort: 554,
|
||||
httpPort: 80,
|
||||
stream: 0,
|
||||
channel: 0,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
label="Tên"
|
||||
name="name"
|
||||
rules={[{ required: true, message: 'Vui lòng nhập tên' }]}
|
||||
>
|
||||
<Input placeholder="nhập dữ liệu" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Loại"
|
||||
name="type"
|
||||
rules={[{ required: true, message: 'Vui lòng chọn loại' }]}
|
||||
>
|
||||
<Select options={CAMERA_TYPES} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Tài khoản"
|
||||
name="account"
|
||||
rules={[{ required: true, message: 'Vui lòng nhập tài khoản' }]}
|
||||
>
|
||||
<Input placeholder="nhập tài khoản" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Mật khẩu"
|
||||
name="password"
|
||||
rules={[{ required: true, message: 'Vui lòng nhập mật khẩu' }]}
|
||||
>
|
||||
<Input.Password placeholder="nhập mật khẩu" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Địa chỉ IP"
|
||||
name="ipAddress"
|
||||
rules={[{ required: true, message: 'Vui lòng nhập địa chỉ IP' }]}
|
||||
>
|
||||
<Input placeholder="192.168.1.10" />
|
||||
</Form.Item>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="Cổng RTSP"
|
||||
name="rtspPort"
|
||||
rules={[
|
||||
{ required: true, message: 'Vui lòng nhập cổng RTSP' },
|
||||
]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="Cổng HTTP"
|
||||
name="httpPort"
|
||||
rules={[
|
||||
{ required: true, message: 'Vui lòng nhập cổng HTTP' },
|
||||
]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="Luồng"
|
||||
name="stream"
|
||||
rules={[{ required: true, message: 'Vui lòng nhập luồng' }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
label="Kênh"
|
||||
name="channel"
|
||||
rules={[{ required: true, message: 'Vui lòng nhập kênh' }]}
|
||||
>
|
||||
<InputNumber style={{ width: '100%' }} min={0} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
|
||||
export default CameraConfigPage;
|
||||
@@ -6,11 +6,7 @@ interface Props {
|
||||
visible: boolean;
|
||||
device: MasterModel.Thing | null;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: {
|
||||
name: string;
|
||||
external_id: string;
|
||||
address?: string;
|
||||
}) => void;
|
||||
onSubmit: (values: MasterModel.Thing) => void;
|
||||
}
|
||||
|
||||
const EditDeviceModal: React.FC<Props> = ({
|
||||
@@ -34,6 +30,24 @@ const EditDeviceModal: React.FC<Props> = ({
|
||||
}
|
||||
}, [device, form]);
|
||||
|
||||
const handleFinish = (values: {
|
||||
name: string;
|
||||
external_id: string;
|
||||
address?: string;
|
||||
}) => {
|
||||
const payload: MasterModel.Thing = {
|
||||
...device,
|
||||
name: values.name,
|
||||
metadata: {
|
||||
...(device?.metadata || {}),
|
||||
external_id: values.external_id,
|
||||
address: values.address,
|
||||
},
|
||||
};
|
||||
|
||||
onSubmit(payload);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={intl.formatMessage({
|
||||
@@ -53,7 +67,12 @@ const EditDeviceModal: React.FC<Props> = ({
|
||||
})}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={onSubmit} preserve={false}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleFinish}
|
||||
preserve={false}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={intl.formatMessage({
|
||||
|
||||
121
src/pages/Manager/Device/components/LocationModal.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Form, Input, Modal } from 'antd';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
device: MasterModel.Thing | null;
|
||||
onCancel: () => void;
|
||||
onSubmit: (values: MasterModel.Thing) => void;
|
||||
}
|
||||
|
||||
const LocationModal: React.FC<Props> = ({
|
||||
visible,
|
||||
device,
|
||||
onCancel,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const intl = useIntl();
|
||||
|
||||
useEffect(() => {
|
||||
if (device) {
|
||||
form.setFieldsValue({
|
||||
lat: device?.metadata?.lat || '',
|
||||
lng: device?.metadata?.lng || '',
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [device, form]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={intl.formatMessage({
|
||||
id: 'master.devices.location.title',
|
||||
defaultMessage: 'Update location',
|
||||
})}
|
||||
open={visible}
|
||||
onCancel={onCancel}
|
||||
onOk={() => form.submit()}
|
||||
okText={intl.formatMessage({
|
||||
id: 'master.devices.ok',
|
||||
defaultMessage: 'OK',
|
||||
})}
|
||||
cancelText={intl.formatMessage({
|
||||
id: 'master.devices.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
})}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={(values) => {
|
||||
const payload: MasterModel.Thing = {
|
||||
id: device?.id,
|
||||
name: device?.name,
|
||||
key: device?.key,
|
||||
metadata: {
|
||||
...device?.metadata,
|
||||
lat: values.lat,
|
||||
lng: values.lng,
|
||||
},
|
||||
};
|
||||
onSubmit(payload);
|
||||
}}
|
||||
preserve={false}
|
||||
>
|
||||
<Form.Item
|
||||
name="lat"
|
||||
label={intl.formatMessage({
|
||||
id: 'master.devices.location.latitude',
|
||||
defaultMessage: 'Latitude',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'master.devices.location.latitude.required',
|
||||
defaultMessage: 'Please enter latitude',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'master.devices.location.placeholder',
|
||||
defaultMessage: 'Enter data',
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="lng"
|
||||
label={intl.formatMessage({
|
||||
id: 'master.devices.location.longitude',
|
||||
defaultMessage: 'Longitude',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'master.devices.location.longitude.required',
|
||||
defaultMessage: 'Please enter longitude',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'master.devices.location.placeholder',
|
||||
defaultMessage: 'Enter data',
|
||||
})}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default LocationModal;
|
||||
@@ -1,7 +1,10 @@
|
||||
import IconFont from '@/components/IconFont';
|
||||
import TreeGroup from '@/components/shared/TreeGroup';
|
||||
import { DEFAULT_PAGE_SIZE } from '@/constants';
|
||||
import { apiSearchThings } from '@/services/master/ThingController';
|
||||
import {
|
||||
apiSearchThings,
|
||||
apiUpdateThing,
|
||||
} from '@/services/master/ThingController';
|
||||
import { EditOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
ActionType,
|
||||
@@ -9,13 +12,14 @@ import {
|
||||
ProColumns,
|
||||
ProTable,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||
import { FormattedMessage, history, useIntl } from '@umijs/max';
|
||||
import { Button, Divider, Grid, Space, Tag, theme } from 'antd';
|
||||
import message from 'antd/es/message';
|
||||
import Paragraph from 'antd/lib/typography/Paragraph';
|
||||
import { useRef, useState } from 'react';
|
||||
import CreateDevice from './components/CreateDevice';
|
||||
import EditDeviceModal from './components/EditDeviceModal';
|
||||
import LocationModal from './components/LocationModal';
|
||||
|
||||
const ManagerDevicePage = () => {
|
||||
const { useBreakpoint } = Grid;
|
||||
@@ -35,9 +39,14 @@ const ManagerDevicePage = () => {
|
||||
const [editingDevice, setEditingDevice] = useState<MasterModel.Thing | null>(
|
||||
null,
|
||||
);
|
||||
const [isLocationModalVisible, setIsLocationModalVisible] =
|
||||
useState<boolean>(false);
|
||||
const [locationDevice, setLocationDevice] =
|
||||
useState<MasterModel.Thing | null>(null);
|
||||
|
||||
const handleClickAssign = (device: MasterModel.Thing) => {
|
||||
console.log('Device ', device);
|
||||
const handleLocation = (device: MasterModel.Thing) => {
|
||||
setLocationDevice(device);
|
||||
setIsLocationModalVisible(true);
|
||||
};
|
||||
|
||||
const handleEdit = (device: MasterModel.Thing) => {
|
||||
@@ -50,13 +59,53 @@ const ManagerDevicePage = () => {
|
||||
setEditingDevice(null);
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (values: any) => {
|
||||
// TODO: call update API here if available. For now just simulate success.
|
||||
console.log('Update values for', editingDevice?.id, values);
|
||||
messageApi.success('Cập nhật thành công');
|
||||
setIsEditModalVisible(false);
|
||||
setEditingDevice(null);
|
||||
actionRef.current?.reload();
|
||||
const handleLocationCancel = () => {
|
||||
setIsLocationModalVisible(false);
|
||||
setLocationDevice(null);
|
||||
};
|
||||
|
||||
const handleLocationSubmit = async (values: MasterModel.Thing) => {
|
||||
try {
|
||||
await apiUpdateThing(values);
|
||||
messageApi.success(
|
||||
intl.formatMessage({
|
||||
id: 'master.devices.location.update.success',
|
||||
defaultMessage: 'Location updated successfully',
|
||||
}),
|
||||
);
|
||||
setIsLocationModalVisible(false);
|
||||
setLocationDevice(null);
|
||||
actionRef.current?.reload();
|
||||
} catch (error) {
|
||||
messageApi.error(
|
||||
intl.formatMessage({
|
||||
id: 'master.devices.location.update.error',
|
||||
defaultMessage: 'Location update failed',
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditSubmit = async (values: MasterModel.Thing) => {
|
||||
try {
|
||||
await apiUpdateThing(values);
|
||||
messageApi.success(
|
||||
intl.formatMessage({
|
||||
id: 'master.devices.update.success',
|
||||
defaultMessage: 'Updated successfully',
|
||||
}),
|
||||
);
|
||||
setIsEditModalVisible(false);
|
||||
setEditingDevice(null);
|
||||
actionRef.current?.reload();
|
||||
} catch (error) {
|
||||
messageApi.error(
|
||||
intl.formatMessage({
|
||||
id: 'master.devices.update.error',
|
||||
defaultMessage: 'Update failed',
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ProColumns<MasterModel.Thing>[] = [
|
||||
@@ -172,20 +221,21 @@ const ManagerDevicePage = () => {
|
||||
shape="default"
|
||||
size="small"
|
||||
icon={<EnvironmentOutlined />}
|
||||
// onClick={() => handleClickAssign(device)}
|
||||
onClick={() => handleLocation(device)}
|
||||
/>
|
||||
<Button
|
||||
shape="default"
|
||||
size="small"
|
||||
icon={<IconFont type="icon-camera" />}
|
||||
// onClick={() => handleClickAssign(device)}
|
||||
onClick={() => {
|
||||
history.push(`/manager/devices/${device.id}/camera`);
|
||||
}}
|
||||
/>
|
||||
{device?.metadata?.type === 'gmsv6' && (
|
||||
<Button
|
||||
shape="default"
|
||||
size="small"
|
||||
icon={<IconFont type="icon-terminal" />}
|
||||
// onClick={() => handleClickAssign(device)}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
@@ -202,6 +252,12 @@ const ManagerDevicePage = () => {
|
||||
onCancel={handleEditCancel}
|
||||
onSubmit={handleEditSubmit}
|
||||
/>
|
||||
<LocationModal
|
||||
visible={isLocationModalVisible}
|
||||
device={locationDevice}
|
||||
onCancel={handleLocationCancel}
|
||||
onSubmit={handleLocationSubmit}
|
||||
/>
|
||||
{contextHolder}
|
||||
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 6, lg: 6, xl: 6 }}>
|
||||
@@ -255,7 +311,7 @@ const ManagerDevicePage = () => {
|
||||
const offset = current === 1 ? 0 : (current - 1) * size;
|
||||
setIsLoading(true);
|
||||
|
||||
const metadata: Partial<MasterModel.ThingMetadata> = {};
|
||||
const metadata: Partial<MasterModel.SearchThingMetadata> = {};
|
||||
if (external_id) metadata.external_id = external_id;
|
||||
|
||||
// Add group filter if groups are selected
|
||||
|
||||
@@ -14,7 +14,7 @@ const SystemLogs = () => {
|
||||
const tableRef = useRef<ActionType>();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const queryUserSource = async (): Promise<MasterModel.ProfileResponse[]> => {
|
||||
const queryUserSource = async (): Promise<MasterModel.UserResponse[]> => {
|
||||
try {
|
||||
const body: MasterModel.SearchUserPaginationBody = {
|
||||
offset: 0,
|
||||
|
||||
@@ -14,7 +14,7 @@ enum AssignTabsKey {
|
||||
const AssignUserPage = () => {
|
||||
const { userId } = useParams<{ userId: string }>();
|
||||
const [userProfile, setUserProfile] =
|
||||
useState<MasterModel.ProfileResponse | null>(null);
|
||||
useState<MasterModel.UserResponse | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [tabSelected, setTabSelected] = useState<AssignTabsKey>(
|
||||
AssignTabsKey.group,
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import { useRef, useState } from 'react';
|
||||
const { Text } = Typography;
|
||||
type AssignGroupProps = {
|
||||
user: MasterModel.ProfileResponse | null;
|
||||
user: MasterModel.UserResponse | null;
|
||||
};
|
||||
const AssignGroup = ({ user }: AssignGroupProps) => {
|
||||
const groupActionRef = useRef<ActionType>();
|
||||
|
||||
@@ -32,7 +32,7 @@ type PolicyShareDefault = {
|
||||
};
|
||||
|
||||
type ShareThingProps = {
|
||||
user: MasterModel.ProfileResponse | null;
|
||||
user: MasterModel.UserResponse | null;
|
||||
};
|
||||
const ShareThing = ({ user }: ShareThingProps) => {
|
||||
const listActionRef = useRef<ActionType>();
|
||||
|
||||
@@ -34,19 +34,19 @@ const ManagerUserPage = () => {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [selectedRowsState, setSelectedRowsState] = useState<
|
||||
MasterModel.ProfileResponse[]
|
||||
MasterModel.UserResponse[]
|
||||
>([]);
|
||||
|
||||
const [groupCheckedKeys, setGroupCheckedKeys] = useState<
|
||||
string | string[] | null
|
||||
>(null);
|
||||
|
||||
const handleClickAssign = (user: MasterModel.ProfileResponse) => {
|
||||
const handleClickAssign = (user: MasterModel.UserResponse) => {
|
||||
const path = `${ROUTE_MANAGER_USERS}/${user.id}/${ROUTE_MANAGER_USERS_PERMISSIONS}`;
|
||||
history.push(path);
|
||||
};
|
||||
|
||||
const columns: ProColumns<MasterModel.ProfileResponse>[] = [
|
||||
const columns: ProColumns<MasterModel.UserResponse>[] = [
|
||||
{
|
||||
key: 'email',
|
||||
title: (
|
||||
@@ -136,7 +136,7 @@ const ManagerUserPage = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const handleRemove = async (selectedRows: MasterModel.ProfileResponse[]) => {
|
||||
const handleRemove = async (selectedRows: MasterModel.UserResponse[]) => {
|
||||
const key = 'remove_user';
|
||||
if (!selectedRows) return true;
|
||||
|
||||
@@ -151,7 +151,7 @@ const ManagerUserPage = () => {
|
||||
key,
|
||||
});
|
||||
const allDelete = selectedRows.map(
|
||||
async (row: MasterModel.ProfileResponse) => {
|
||||
async (row: MasterModel.UserResponse) => {
|
||||
await apiDeleteUser(row?.id || '');
|
||||
},
|
||||
);
|
||||
@@ -196,7 +196,7 @@ const ManagerUserPage = () => {
|
||||
/>
|
||||
</ProCard>
|
||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 18, lg: 18, xl: 18 }}>
|
||||
<ProTable<MasterModel.ProfileResponse>
|
||||
<ProTable<MasterModel.UserResponse>
|
||||
columns={columns}
|
||||
tableLayout="auto"
|
||||
actionRef={actionRef}
|
||||
@@ -210,7 +210,7 @@ const ManagerUserPage = () => {
|
||||
selectedRowKeys: selectedRowsState.map((row) => row.id!),
|
||||
onChange: (
|
||||
_: React.Key[],
|
||||
selectedRows: MasterModel.ProfileResponse[],
|
||||
selectedRows: MasterModel.UserResponse[],
|
||||
) => {
|
||||
setSelectedRowsState(selectedRows);
|
||||
},
|
||||
@@ -249,12 +249,12 @@ const ManagerUserPage = () => {
|
||||
let users = userByGroupResponses.users || [];
|
||||
// Apply filters
|
||||
if (email) {
|
||||
users = users.filter((user: MasterModel.ProfileResponse) =>
|
||||
users = users.filter((user: MasterModel.UserResponse) =>
|
||||
user.email?.includes(email),
|
||||
);
|
||||
}
|
||||
if (phone_number) {
|
||||
users = users.filter((user: MasterModel.ProfileResponse) =>
|
||||
users = users.filter((user: MasterModel.UserResponse) =>
|
||||
user.metadata?.phone_number?.includes(phone_number),
|
||||
);
|
||||
}
|
||||
@@ -269,7 +269,7 @@ const ManagerUserPage = () => {
|
||||
};
|
||||
} else {
|
||||
// Use regular queryUsers API
|
||||
const metadata: Partial<MasterModel.ProfileMetadata> = {};
|
||||
const metadata: Partial<MasterModel.UserMetadata> = {};
|
||||
if (phone_number) metadata.phone_number = phone_number;
|
||||
|
||||
const query: MasterModel.SearchUserPaginationBody = {
|
||||
|
||||
@@ -32,7 +32,7 @@ const ChangeProfile = () => {
|
||||
}}
|
||||
onFinish={async (values) => {
|
||||
try {
|
||||
const body: Partial<MasterModel.ProfileMetadata> = {
|
||||
const body: Partial<MasterModel.UserMetadata> = {
|
||||
full_name: values.full_name,
|
||||
phone_number: values.phone_number,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { ModalForm } from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||
import { Button, DatePicker, Flex, Form, Input, Select, Space } from 'antd';
|
||||
import { FormListFieldData } from 'antd/lib';
|
||||
import dayjs from 'dayjs';
|
||||
import { useEffect } from 'react';
|
||||
import { AreaCondition, ZoneFormField } from '../type';
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
// Transform form values to match AreaCondition type
|
||||
const transformToAreaCondition = (values: any[]): AreaCondition[] => {
|
||||
return values.map((item) => {
|
||||
const { type } = item;
|
||||
|
||||
switch (type) {
|
||||
case 'month_range': {
|
||||
// RangePicker for month returns [Dayjs, Dayjs]
|
||||
const [from, to] = item.from ?? [];
|
||||
return {
|
||||
type: 'month_range',
|
||||
from: from?.month() ?? 0, // 0-11
|
||||
to: to?.month() ?? 0,
|
||||
};
|
||||
}
|
||||
case 'date_range': {
|
||||
// RangePicker for date returns [Dayjs, Dayjs]
|
||||
const [from, to] = item.from ?? [];
|
||||
return {
|
||||
type: 'date_range',
|
||||
from: from?.format('YYYY-MM-DD') ?? '',
|
||||
to: to?.format('YYYY-MM-DD') ?? '',
|
||||
};
|
||||
}
|
||||
case 'length_limit': {
|
||||
return {
|
||||
type: 'length_limit',
|
||||
min: Number(item.min) ?? 0,
|
||||
max: Number(item.max) ?? 0,
|
||||
};
|
||||
}
|
||||
default:
|
||||
return item;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Transform AreaCondition to form values
|
||||
const transformToFormValues = (conditions?: AreaCondition[]): any[] => {
|
||||
if (!conditions || conditions.length === 0) return [{}];
|
||||
|
||||
return conditions.map((condition) => {
|
||||
switch (condition.type) {
|
||||
case 'month_range': {
|
||||
return {
|
||||
type: 'month_range',
|
||||
from: [dayjs().month(condition.from), dayjs().month(condition.to)],
|
||||
};
|
||||
}
|
||||
case 'date_range': {
|
||||
return {
|
||||
type: 'date_range',
|
||||
from: [dayjs(condition.from), dayjs(condition.to)],
|
||||
};
|
||||
}
|
||||
case 'length_limit': {
|
||||
return condition;
|
||||
}
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const ConditionRow = ({
|
||||
field,
|
||||
remove,
|
||||
}: {
|
||||
field: FormListFieldData;
|
||||
remove: (name: number) => void;
|
||||
}) => {
|
||||
const selectedType = Form.useWatch([
|
||||
ZoneFormField.AreaConditions,
|
||||
field.name,
|
||||
'type',
|
||||
]);
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Flex gap="middle" style={{ marginBottom: 16 }}>
|
||||
<Space align="baseline">
|
||||
<Form.Item
|
||||
name={[field.name, 'type']}
|
||||
label={intl.formatMessage({
|
||||
id: 'banzone.category.name',
|
||||
defaultMessage: 'Danh mục',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'banzone.category.not_empty',
|
||||
defaultMessage: 'Category cannot be empty!',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'banzone.category.add',
|
||||
defaultMessage: 'Add category',
|
||||
})}
|
||||
>
|
||||
<Select.Option value="month_range">
|
||||
<FormattedMessage
|
||||
id="banzone.condition.yearly_select"
|
||||
defaultMessage="Yearly"
|
||||
/>
|
||||
</Select.Option>
|
||||
<Select.Option value="date_range">
|
||||
<FormattedMessage
|
||||
id="banzone.condition.specific_time_select"
|
||||
defaultMessage="Specific date"
|
||||
/>
|
||||
</Select.Option>
|
||||
<Select.Option value="length_limit">
|
||||
<FormattedMessage
|
||||
id="banzone.condition.length_limit"
|
||||
defaultMessage="Length limit"
|
||||
/>
|
||||
</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
{selectedType !== undefined &&
|
||||
(selectedType === 'length_limit' ? (
|
||||
<Form.Item
|
||||
label={intl.formatMessage({
|
||||
id: 'banzone.condition.length_limit',
|
||||
defaultMessage: 'Length limit',
|
||||
})}
|
||||
required
|
||||
>
|
||||
<Space>
|
||||
<Form.Item
|
||||
name={[field.name, 'min']}
|
||||
noStyle
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'common.not_empty',
|
||||
defaultMessage: 'Cannot be empty!',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'banzone.condition.length_limit_min',
|
||||
defaultMessage: 'Minimum',
|
||||
})}
|
||||
type="number"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={[field.name, 'max']}
|
||||
noStyle
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'common.not_empty',
|
||||
defaultMessage: 'Cannot be empty!',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'banzone.condition.length_limit_max',
|
||||
defaultMessage: 'Maximum',
|
||||
})}
|
||||
type="number"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item
|
||||
name={[field.name, 'from']}
|
||||
label={intl.formatMessage({
|
||||
id: 'banzone.condition.ban_time',
|
||||
defaultMessage: 'Time',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'common.not_empty',
|
||||
defaultMessage: 'Cannot be empty!',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<RangePicker
|
||||
picker={selectedType === 'month_range' ? 'month' : 'date'}
|
||||
format={
|
||||
selectedType === 'month_range' ? 'MM/YYYY' : 'DD/MM/YYYY'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} />
|
||||
</Space>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
interface AddConditionFormProps {
|
||||
initialData?: AreaCondition[];
|
||||
isVisible: boolean;
|
||||
setVisible: (visible: boolean) => void;
|
||||
onFinish?: (values: AreaCondition[]) => void;
|
||||
}
|
||||
|
||||
const AddConditionForm = ({
|
||||
initialData,
|
||||
isVisible,
|
||||
setVisible,
|
||||
onFinish,
|
||||
}: AddConditionFormProps) => {
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
form.resetFields();
|
||||
|
||||
if (initialData && initialData.length > 0) {
|
||||
form.setFieldsValue({ conditions: transformToFormValues(initialData) });
|
||||
} else {
|
||||
form.setFieldsValue({ conditions: [{}] });
|
||||
}
|
||||
}
|
||||
}, [isVisible, initialData]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const transformedConditions = transformToAreaCondition(values.conditions);
|
||||
onFinish?.(transformedConditions);
|
||||
setVisible(false);
|
||||
form.resetFields();
|
||||
} catch (err) {
|
||||
console.error('Validation failed', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalForm
|
||||
form={form}
|
||||
open={isVisible}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) form.resetFields();
|
||||
setVisible(open);
|
||||
}}
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="banzone.category.add"
|
||||
defaultMessage="Add category"
|
||||
/>
|
||||
}
|
||||
width="600px"
|
||||
submitter={{
|
||||
searchConfig: { submitText: 'Lưu' },
|
||||
render: (_, doms) => (
|
||||
<Flex justify="center" gap={16}>
|
||||
{doms}
|
||||
</Flex>
|
||||
),
|
||||
}}
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
<Form.List name={ZoneFormField.AreaConditions}>
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<ConditionRow
|
||||
key={field.key}
|
||||
field={field}
|
||||
remove={remove}
|
||||
// isFirst={index === 0}
|
||||
/>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Flex justify="center">
|
||||
<Button
|
||||
type="dashed"
|
||||
onClick={() => add()}
|
||||
icon={<PlusOutlined />}
|
||||
style={{ width: 300 }}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="banzone.category.add"
|
||||
defaultMessage="Add category"
|
||||
/>
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddConditionForm;
|
||||
@@ -0,0 +1,334 @@
|
||||
import { getCircleRadius } from '@/utils/slave/sgw/geomUtils';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
ProFormDigit,
|
||||
ProFormInstance,
|
||||
ProFormItem,
|
||||
ProFormText,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||
import { Button, Col, Flex, Form, Input, Row, Tag, Tooltip } from 'antd';
|
||||
import { MutableRefObject, useMemo, useState } from 'react';
|
||||
import {
|
||||
CircleGeometry,
|
||||
GeometryType,
|
||||
PolygonGeometry,
|
||||
tagPlusStyle,
|
||||
validateGeometry,
|
||||
ZoneFormData,
|
||||
ZoneFormField,
|
||||
} from '../type';
|
||||
import PolygonModal from './PolygonModal';
|
||||
|
||||
type GeometryFormProps = {
|
||||
shape?: number;
|
||||
form: MutableRefObject<ProFormInstance<ZoneFormData> | null | undefined>;
|
||||
zoneData?: SgwModel.Geom;
|
||||
};
|
||||
|
||||
const GeometryForm = ({ shape, form }: GeometryFormProps) => {
|
||||
const [isPolygonModalOpen, setIsPolygonModalOpen] = useState<boolean>(false);
|
||||
const [indexTag, setIndexTag] = useState<number>(-1);
|
||||
const intl = useIntl();
|
||||
const polygonGeometry =
|
||||
(Form.useWatch(
|
||||
ZoneFormField.PolygonGeometry,
|
||||
form.current || undefined,
|
||||
) as PolygonGeometry[]) || [];
|
||||
|
||||
// Circle area calculation (subscribe to Radius so it updates)
|
||||
const area = Form.useWatch(
|
||||
[ZoneFormField.CircleData, 'area'],
|
||||
form.current || undefined,
|
||||
) as number | undefined;
|
||||
|
||||
const radiusArea = useMemo(() => {
|
||||
if (shape === GeometryType.CIRCLE) {
|
||||
if (area && area > 0) {
|
||||
return getCircleRadius(area);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}, [shape, area]);
|
||||
|
||||
const handleRemovePolygon = (index: number) => {
|
||||
const newPolygons = polygonGeometry.filter(
|
||||
(_, i) => i !== index,
|
||||
) as PolygonGeometry[];
|
||||
form.current?.setFieldsValue({
|
||||
[ZoneFormField.PolygonGeometry]: newPolygons,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditPolygon = (index: number) => {
|
||||
setIsPolygonModalOpen(true);
|
||||
setIndexTag(index);
|
||||
};
|
||||
|
||||
// Format coordinates for display
|
||||
const formatCoords = (coords: number[][]) => {
|
||||
return coords.map((c) => `[${c[0]}, ${c[1]}]`).join(', ');
|
||||
};
|
||||
|
||||
switch (shape) {
|
||||
case GeometryType.POLYGON:
|
||||
return (
|
||||
<>
|
||||
<ProFormItem
|
||||
name={ZoneFormField.PolygonGeometry}
|
||||
label={intl.formatMessage({
|
||||
id: 'banzone.geometry.coordinates',
|
||||
defaultMessage: 'Tọa độ',
|
||||
})}
|
||||
required
|
||||
>
|
||||
<Flex gap="10px 4px" wrap>
|
||||
{polygonGeometry.map((polygon, index) => (
|
||||
<Tooltip
|
||||
key={index}
|
||||
title={formatCoords(polygon.geometry) || ''}
|
||||
>
|
||||
<Tag
|
||||
closable
|
||||
onClose={(e) => {
|
||||
e.preventDefault();
|
||||
handleRemovePolygon(index);
|
||||
}}
|
||||
onClick={() => handleEditPolygon(index)}
|
||||
color="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: 'banzones.title',
|
||||
defaultMessage: 'Khu vực',
|
||||
})}{' '}
|
||||
{index + 1}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
))}
|
||||
<Button
|
||||
style={tagPlusStyle}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setIndexTag(-1);
|
||||
setIsPolygonModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="banzone.geometry.add_zone"
|
||||
defaultMessage="Thêm vùng"
|
||||
/>
|
||||
</Button>
|
||||
</Flex>
|
||||
</ProFormItem>
|
||||
<PolygonModal
|
||||
isVisible={isPolygonModalOpen}
|
||||
setVisible={setIsPolygonModalOpen}
|
||||
initialData={polygonGeometry}
|
||||
index={indexTag}
|
||||
handleSubmit={async (values) => {
|
||||
form.current?.setFieldsValue({
|
||||
[ZoneFormField.PolygonGeometry]: values,
|
||||
});
|
||||
setIndexTag(-1);
|
||||
setIsPolygonModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
case GeometryType.LINESTRING:
|
||||
return (
|
||||
<ProFormItem
|
||||
required
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'banzone.geometry.coordinates.not_empty',
|
||||
defaultMessage: 'Tọa độ không được để trống!',
|
||||
}),
|
||||
},
|
||||
{
|
||||
validator: (_: any, value: string) => {
|
||||
return validateGeometry(value);
|
||||
},
|
||||
},
|
||||
]}
|
||||
name={ZoneFormField.PolylineData}
|
||||
label={intl.formatMessage({
|
||||
id: 'banzone.geometry.coordinates',
|
||||
defaultMessage: 'Tọa độ',
|
||||
})}
|
||||
tooltip={intl.formatMessage({
|
||||
id: 'banzone.geometry.coordinates.tooltip',
|
||||
defaultMessage:
|
||||
'Danh sách các toạ độ theo định dạng: [[longitude,latitude],[longitude,latitude],...]',
|
||||
})}
|
||||
>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 5, maxRows: 10 }}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'banzone.geometry.coordinates.placeholder',
|
||||
defaultMessage:
|
||||
'Ví dụ: [[109.5, 12.3], [109.6, 12.4], [109.7, 12.5]]',
|
||||
})}
|
||||
/>
|
||||
</ProFormItem>
|
||||
);
|
||||
|
||||
case GeometryType.CIRCLE:
|
||||
return (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<ProFormText
|
||||
name={[ZoneFormField.CircleData, 'center', 0]}
|
||||
label={intl.formatMessage({
|
||||
id: 'banzone.geometry.longitude',
|
||||
defaultMessage: 'Kinh độ',
|
||||
})}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'banzone.geometry.longitude.placeholder',
|
||||
defaultMessage: 'Nhập kinh độ',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'banzone.geometry.longitude.required',
|
||||
defaultMessage: 'Kinh độ không được để trống!',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
fieldProps={{
|
||||
type: 'number',
|
||||
step: '0.000001',
|
||||
onChange: (e) => {
|
||||
const lng = parseFloat(e.target.value);
|
||||
const currentCircle = (form.current?.getFieldValue(
|
||||
ZoneFormField.CircleData,
|
||||
) as CircleGeometry) || { center: [0, 0], area: 0 };
|
||||
form.current?.setFieldsValue({
|
||||
[ZoneFormField.CircleData]: {
|
||||
...currentCircle,
|
||||
center: [lng, currentCircle.center?.[1] || 0],
|
||||
},
|
||||
});
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<ProFormText
|
||||
name={[ZoneFormField.CircleData, 'center', 1]}
|
||||
label={intl.formatMessage({
|
||||
id: 'banzone.geometry.latitude',
|
||||
defaultMessage: 'Vĩ độ',
|
||||
})}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'banzone.geometry.latitude.placeholder',
|
||||
defaultMessage: 'Nhập vĩ độ',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'banzone.geometry.latitude.required',
|
||||
defaultMessage: 'Vĩ độ không được để trống!',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
fieldProps={{
|
||||
type: 'number',
|
||||
step: '0.000001',
|
||||
onChange: (e) => {
|
||||
const lat = parseFloat(e.target.value);
|
||||
const currentCircle = (form.current?.getFieldValue(
|
||||
ZoneFormField.CircleData,
|
||||
) as CircleGeometry) || { center: [0, 0], radius: 0 };
|
||||
form.current?.setFieldsValue({
|
||||
[ZoneFormField.CircleData]: {
|
||||
...currentCircle,
|
||||
center: [currentCircle.center?.[0] || 0, lat],
|
||||
},
|
||||
});
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<ProFormDigit
|
||||
name={[ZoneFormField.CircleData, 'area']}
|
||||
label={intl.formatMessage({
|
||||
id: 'banzone.geometry.area',
|
||||
defaultMessage: 'Diện tích (Hecta)',
|
||||
})}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'banzone.geometry.area.placeholder',
|
||||
defaultMessage: 'Nhập diện tích',
|
||||
})}
|
||||
min={1}
|
||||
fieldProps={{
|
||||
precision: 0,
|
||||
onChange: (value) => {
|
||||
const currentCircle = (form.current?.getFieldValue(
|
||||
ZoneFormField.CircleData,
|
||||
) as CircleGeometry) || { center: [0, 0], radius: 0 };
|
||||
form.current?.setFieldsValue({
|
||||
[ZoneFormField.CircleData]: {
|
||||
center: currentCircle.center || [0, 0],
|
||||
area: value || 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
}}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'banzone.geometry.area.required',
|
||||
defaultMessage: 'Diện tích không được để trống!',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<ProFormItem
|
||||
name={[ZoneFormField.CircleData, 'radius']}
|
||||
label={intl.formatMessage({
|
||||
id: 'banzone.geometry.radius',
|
||||
defaultMessage: 'Bán kính (m)',
|
||||
})}
|
||||
>
|
||||
<Input
|
||||
disabled
|
||||
readOnly
|
||||
value={
|
||||
radiusArea > 0
|
||||
? `${radiusArea} ${intl.formatMessage({
|
||||
id: 'banzone.geometry.metrics',
|
||||
defaultMessage: 'mét',
|
||||
})}`
|
||||
: ''
|
||||
}
|
||||
addonAfter={intl.formatMessage({
|
||||
id: 'banzone.geometry.auto_calculate',
|
||||
defaultMessage: 'Tự động tính',
|
||||
})}
|
||||
/>
|
||||
</ProFormItem>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div>Vui lòng chọn loại hình học để nhập toạ độ.</div>;
|
||||
}
|
||||
};
|
||||
|
||||
export default GeometryForm;
|
||||
@@ -0,0 +1,175 @@
|
||||
import {
|
||||
ModalForm,
|
||||
ProFormItem,
|
||||
ProFormList,
|
||||
} from '@ant-design/pro-components';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Flex, Input, theme } from 'antd';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { PolygonGeometry, validateGeometry } from '../type';
|
||||
|
||||
interface PolygonModalProps {
|
||||
initialData?: PolygonGeometry[];
|
||||
index?: number;
|
||||
isVisible: boolean;
|
||||
setVisible: (visible: boolean) => void;
|
||||
handleSubmit: (values: PolygonGeometry[]) => Promise<void>;
|
||||
}
|
||||
|
||||
const PolygonModal = ({
|
||||
isVisible,
|
||||
setVisible,
|
||||
handleSubmit,
|
||||
initialData,
|
||||
index,
|
||||
}: PolygonModalProps) => {
|
||||
const formRef = useRef<any>();
|
||||
const { token } = theme.useToken();
|
||||
const intl = useIntl();
|
||||
// Counter to track item index during render
|
||||
let itemIndex = 0;
|
||||
// Convert initialData to form format
|
||||
const initialValues = useMemo(() => {
|
||||
if (!initialData || initialData.length === 0) {
|
||||
return { conditions: [] };
|
||||
}
|
||||
return {
|
||||
conditions: initialData.map((item) => ({
|
||||
geometry: JSON.stringify(item.geometry),
|
||||
id: item.id || undefined,
|
||||
})),
|
||||
};
|
||||
}, [initialData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
formRef.current?.setFieldsValue(initialValues);
|
||||
}
|
||||
}, [isVisible, initialValues]);
|
||||
|
||||
// Handle form submit - convert back to AreaGeometry format
|
||||
const handleFinish = async (values: any) => {
|
||||
const conditions = values.conditions || [];
|
||||
const result: PolygonGeometry[] = conditions.map((item: any) => ({
|
||||
geometry: JSON.parse(item.geometry),
|
||||
id: item.id,
|
||||
}));
|
||||
await handleSubmit(result);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Global style for highlighting TextArea border */}
|
||||
<style>{`
|
||||
.highlighted-item .ant-input {
|
||||
border-color: ${token.colorWarningActive} !important;
|
||||
}
|
||||
`}</style>
|
||||
<ModalForm
|
||||
formRef={formRef}
|
||||
open={isVisible}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) formRef.current?.resetFields();
|
||||
setVisible(open);
|
||||
}}
|
||||
title={intl.formatMessage({
|
||||
id: 'banzone.polygon_modal.title',
|
||||
defaultMessage: 'Thêm toạ độ',
|
||||
})}
|
||||
width="40%"
|
||||
initialValues={initialValues}
|
||||
submitter={{
|
||||
searchConfig: {
|
||||
submitText: intl.formatMessage({
|
||||
id: 'common.save',
|
||||
defaultMessage: 'Lưu',
|
||||
}),
|
||||
},
|
||||
render: (_, doms) => (
|
||||
<Flex justify="center" gap={16}>
|
||||
{doms}
|
||||
</Flex>
|
||||
),
|
||||
}}
|
||||
onFinish={handleFinish}
|
||||
>
|
||||
<ProFormList
|
||||
required
|
||||
name="conditions"
|
||||
creatorButtonProps={{
|
||||
position: 'bottom',
|
||||
creatorButtonText: intl.formatMessage({
|
||||
id: 'banzone.geometry.add_zone',
|
||||
defaultMessage: 'Thêm toạ độ',
|
||||
}),
|
||||
}}
|
||||
copyIconProps={false}
|
||||
deleteIconProps={{
|
||||
tooltipText: intl.formatMessage({
|
||||
id: 'common.delete',
|
||||
defaultMessage: 'Xoá',
|
||||
}),
|
||||
}}
|
||||
itemRender={({ listDom, action }) => {
|
||||
const currentIndex = itemIndex++;
|
||||
const isHighlighted = index !== undefined && currentIndex === index;
|
||||
return (
|
||||
<div
|
||||
className={isHighlighted ? 'highlighted-item' : ''}
|
||||
data-item-index={currentIndex}
|
||||
>
|
||||
{listDom}
|
||||
<Flex gap={8} justify="flex-end">
|
||||
{action}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{/* Hidden field for id - needed to preserve id when editing */}
|
||||
<ProFormItem name="id" hidden>
|
||||
<input type="hidden" />
|
||||
</ProFormItem>
|
||||
<ProFormItem
|
||||
name="geometry"
|
||||
required
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'banzone.geometry.coordinates.not_empty',
|
||||
defaultMessage: 'Coordinates cannot be empty!',
|
||||
}),
|
||||
},
|
||||
{
|
||||
validator: (_rule: any, value: any) => {
|
||||
return validateGeometry(value);
|
||||
},
|
||||
},
|
||||
]}
|
||||
label={intl.formatMessage({
|
||||
id: 'banzone.geometry.coordinates',
|
||||
defaultMessage: 'Coordinates',
|
||||
})}
|
||||
tooltip={intl.formatMessage({
|
||||
id: 'banzone.geometry.coordinates.tooltip',
|
||||
defaultMessage:
|
||||
'Danh sách các toạ độ theo định dạng: [[longitude,latitude],[longitude,latitude],...]',
|
||||
})}
|
||||
>
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 5, maxRows: 10 }}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'banzone.geometry.coordinates.placeholder',
|
||||
defaultMessage:
|
||||
'Example: [[109.5, 12.3], [109.6, 12.4], [109.7, 12.5]]',
|
||||
})}
|
||||
/>
|
||||
</ProFormItem>
|
||||
</ProFormList>
|
||||
</ModalForm>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PolygonModal;
|
||||
@@ -0,0 +1,273 @@
|
||||
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
ProForm,
|
||||
ProFormInstance,
|
||||
ProFormSelect,
|
||||
ProFormSwitch,
|
||||
ProFormText,
|
||||
ProFormTextArea,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||
import { Button, Col, Flex, Form, Row, Tag, Tooltip } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { MutableRefObject, useState } from 'react';
|
||||
import { tagPlusStyle, ZoneFormData, ZoneFormField } from '../type';
|
||||
import AddConditionForm from './AddConditionForm';
|
||||
import GeometryForm from './GeometryForm';
|
||||
interface ZoneFormProps {
|
||||
shape?: number;
|
||||
formRef: MutableRefObject<ProFormInstance<ZoneFormData> | null | undefined>;
|
||||
}
|
||||
|
||||
const ZoneForm = ({ formRef, shape }: ZoneFormProps) => {
|
||||
const intl = useIntl();
|
||||
const [isConditionModalOpen, setIsConditionModalOpen] = useState(false);
|
||||
const handleGroupSelect = (groupId: string | string[] | null) => {
|
||||
formRef.current?.setFieldsValue({ [ZoneFormField.AreaId]: groupId });
|
||||
};
|
||||
const selectedGroupIds = Form.useWatch(
|
||||
ZoneFormField.AreaId,
|
||||
formRef.current || undefined,
|
||||
) as string | string[] | null | undefined;
|
||||
const conditionData = Form.useWatch(
|
||||
ZoneFormField.AreaConditions,
|
||||
formRef.current || undefined,
|
||||
);
|
||||
|
||||
const handleConditionsClose = (indexToRemove: number) => {
|
||||
formRef.current?.setFieldValue(
|
||||
ZoneFormField.AreaConditions,
|
||||
conditionData?.filter((_, index) => index !== indexToRemove),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<Row gutter={16}>
|
||||
{/* Tên */}
|
||||
<Col xs={24} md={12}>
|
||||
<ProFormText
|
||||
name={ZoneFormField.AreaName}
|
||||
label={intl.formatMessage({
|
||||
id: 'common.name',
|
||||
defaultMessage: 'Tên',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'banzone.form.name.required.message',
|
||||
defaultMessage: 'Tên khu vực không được để trống!',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
required
|
||||
/>
|
||||
</Col>
|
||||
|
||||
{/* Loại */}
|
||||
<Col xs={24} md={12}>
|
||||
<ProFormSelect
|
||||
name={ZoneFormField.AreaType}
|
||||
label={intl.formatMessage({
|
||||
id: 'common.type',
|
||||
defaultMessage: 'Loại',
|
||||
})}
|
||||
required
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'banzone.form.area_type.required.message',
|
||||
defaultMessage: 'Loại khu vực không được để trống',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'banzone.form.area_type.placeholder',
|
||||
defaultMessage: 'Chọn loại khu vực',
|
||||
})}
|
||||
options={[
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.area.fishing_ban',
|
||||
defaultMessage: 'Cấm đánh bắt',
|
||||
}),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.area.move_ban',
|
||||
defaultMessage: 'Cấm di chuyển',
|
||||
}),
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.area.safe',
|
||||
defaultMessage: 'Vùng an toàn',
|
||||
}),
|
||||
value: 3,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16}>
|
||||
{/* Tỉnh */}
|
||||
<Col xs={24} md={12}>
|
||||
<ProForm.Item
|
||||
name={ZoneFormField.AreaId}
|
||||
label={intl.formatMessage({
|
||||
id: 'common.province',
|
||||
defaultMessage: 'Tỉnh',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'banzone.form.province.required.message',
|
||||
defaultMessage: 'Tỉnh quản lý không được để trống!',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TreeSelectedGroup
|
||||
groupIds={selectedGroupIds ?? ''}
|
||||
onSelected={handleGroupSelect}
|
||||
/>
|
||||
</ProForm.Item>
|
||||
</Col>
|
||||
|
||||
{/* Có hiệu lực */}
|
||||
<Col xs={24} md={12}>
|
||||
<ProFormSwitch
|
||||
name={ZoneFormField.AreaEnabled}
|
||||
label={intl.formatMessage({
|
||||
id: 'banzone.is_enable',
|
||||
defaultMessage: 'Có hiệu lực',
|
||||
})}
|
||||
valuePropName="checked"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<ProFormTextArea
|
||||
name={ZoneFormField.AreaDescription}
|
||||
label={intl.formatMessage({
|
||||
id: 'common.description',
|
||||
defaultMessage: 'Mô tả',
|
||||
})}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'banzone.form.description.placeholder',
|
||||
defaultMessage: 'Nhập mô tả khu vực',
|
||||
})}
|
||||
autoSize={{ minRows: 3, maxRows: 5 }}
|
||||
/>
|
||||
<Form.Item
|
||||
name={ZoneFormField.AreaConditions}
|
||||
label={intl.formatMessage({
|
||||
id: 'banzone.condition',
|
||||
defaultMessage: 'Điều kiện',
|
||||
})}
|
||||
>
|
||||
<Flex gap="10px 4px" wrap>
|
||||
{(conditionData || []).map((condition, index) => {
|
||||
// console.log("Condition: ", condition);
|
||||
|
||||
let tootip = '';
|
||||
let label = '';
|
||||
|
||||
const { type } = condition;
|
||||
switch (type) {
|
||||
case 'month_range': {
|
||||
label = intl.formatMessage({
|
||||
id: 'banzone.condition.yearly',
|
||||
defaultMessage: 'Hàng năm',
|
||||
});
|
||||
const fromMonth = condition.from + 1;
|
||||
const toMonth = condition.to + 1;
|
||||
tootip = `Tháng từ ${fromMonth} đến tháng ${toMonth}`;
|
||||
|
||||
break;
|
||||
}
|
||||
case 'date_range': {
|
||||
label = intl.formatMessage({
|
||||
id: 'banzone.condition.specific_time',
|
||||
defaultMessage: 'Thời gian cụ thể',
|
||||
});
|
||||
|
||||
const fromDate = dayjs(condition.from).format('DD/MM/YYYY');
|
||||
const toDate = dayjs(condition.to).format('DD/MM/YYYY');
|
||||
tootip = `Từ ${fromDate} đến ${toDate}`;
|
||||
|
||||
break;
|
||||
}
|
||||
case 'length_limit':
|
||||
label = intl.formatMessage({
|
||||
id: 'banzone.condition.length_limit',
|
||||
defaultMessage: 'Chiều dài cho phép',
|
||||
});
|
||||
tootip = `Chiều dài tàu từ ${condition.min} đến ${condition.max} mét`;
|
||||
break;
|
||||
default:
|
||||
label = intl.formatMessage({
|
||||
id: 'common.undefined',
|
||||
defaultMessage: 'Không xác định',
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip title={tootip} key={index}>
|
||||
<Tag
|
||||
closable
|
||||
onClick={() => setIsConditionModalOpen(true)}
|
||||
onClose={(e) => {
|
||||
e.preventDefault();
|
||||
handleConditionsClose(index);
|
||||
}}
|
||||
color={
|
||||
type === 'month_range'
|
||||
? 'blue'
|
||||
: type === 'date_range'
|
||||
? 'green'
|
||||
: 'volcano'
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
style={tagPlusStyle}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setIsConditionModalOpen(true)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="banzone.condition.add"
|
||||
defaultMessage="Thêm điều kiện"
|
||||
/>
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
<GeometryForm shape={shape} form={formRef} />
|
||||
<AddConditionForm
|
||||
isVisible={isConditionModalOpen}
|
||||
setVisible={setIsConditionModalOpen}
|
||||
initialData={conditionData}
|
||||
onFinish={(newConditions) => {
|
||||
try {
|
||||
formRef.current?.setFieldValue(
|
||||
ZoneFormField.AreaConditions,
|
||||
newConditions,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Error setting form value:', e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZoneForm;
|
||||
@@ -0,0 +1 @@
|
||||
export { useMapGeometrySync } from './useMapGeometrySync';
|
||||
@@ -0,0 +1,304 @@
|
||||
import { BaseMap, ZoneData } from '@/pages/Slave/SGW/Map/type';
|
||||
import {
|
||||
getAreaFromRadius,
|
||||
getCircleRadius,
|
||||
} from '@/utils/slave/sgw/geomUtils';
|
||||
import { type ProFormInstance } from '@ant-design/pro-components';
|
||||
import { Feature } from 'ol';
|
||||
import { MutableRefObject, useEffect, useRef } from 'react';
|
||||
import type { ZoneFormData } from '../type';
|
||||
import { GeometryType, ZoneFormField } from '../type';
|
||||
|
||||
// Default zone data for drawing features
|
||||
const DEFAULT_ZONE_DATA: ZoneData = { type: 'default' };
|
||||
|
||||
interface DrawnFeatureData {
|
||||
type: string;
|
||||
coordinates: any;
|
||||
feature: any;
|
||||
}
|
||||
|
||||
interface MapGeometrySyncOptions {
|
||||
baseMap: MutableRefObject<BaseMap | null>;
|
||||
dataLayerId: string;
|
||||
form: MutableRefObject<ProFormInstance<ZoneFormData> | undefined>;
|
||||
shape?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to sync geometry between Map and Form
|
||||
* - Draw on Map -> Update Form
|
||||
* - Modify on Map -> Update Form
|
||||
* - Form changes -> Update Map (optional)
|
||||
*/
|
||||
export const useMapGeometrySync = (options: MapGeometrySyncOptions) => {
|
||||
const { baseMap, dataLayerId, form, enabled = true } = options;
|
||||
const isHandlingUpdateRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!baseMap || !enabled) return;
|
||||
|
||||
// Handle feature drawn event
|
||||
const handleFeatureDrawn = (data: DrawnFeatureData) => {
|
||||
if (isHandlingUpdateRef.current) return;
|
||||
isHandlingUpdateRef.current = true;
|
||||
|
||||
switch (data.type) {
|
||||
case 'Polygon': {
|
||||
const currentGeometry =
|
||||
form.current?.getFieldValue(ZoneFormField.PolygonGeometry) || [];
|
||||
const polygonId = `polygon_${Date.now()}`; // Tạo unique ID
|
||||
data.feature.set('polygonId', polygonId); // Sửa: set trực tiếp key-value
|
||||
const polygonCoords = data.coordinates;
|
||||
form.current?.setFieldsValue({
|
||||
[ZoneFormField.PolygonGeometry]: [
|
||||
...currentGeometry,
|
||||
{
|
||||
geometry: polygonCoords[0],
|
||||
id: polygonId,
|
||||
},
|
||||
],
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'LineString': {
|
||||
baseMap.current?.clearFeatures(dataLayerId);
|
||||
const lineCoords = data.coordinates;
|
||||
form.current?.setFieldsValue({
|
||||
[ZoneFormField.PolylineData]: JSON.stringify(lineCoords),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Circle': {
|
||||
baseMap.current?.clearFeatures(dataLayerId);
|
||||
const circleData = data.coordinates;
|
||||
console.log('Circle Data: ', circleData);
|
||||
form.current?.setFieldsValue({
|
||||
[ZoneFormField.CircleData]: {
|
||||
center: circleData.center,
|
||||
area: getAreaFromRadius(circleData.radius),
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Point':
|
||||
// Point is stored as circle with small radius
|
||||
// form?.setFieldsValue({
|
||||
// [ZoneFormField.CircleData]: {
|
||||
// center: data.coordinates,
|
||||
// radius: 100, // Default radius for point
|
||||
// },
|
||||
// [ZoneFormField.Radius]: 100,
|
||||
// });
|
||||
break;
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isHandlingUpdateRef.current = false;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Handle feature modified event
|
||||
const handleFeatureModified = (data: {
|
||||
feature: any;
|
||||
coordinates: any;
|
||||
}) => {
|
||||
if (isHandlingUpdateRef.current) return;
|
||||
isHandlingUpdateRef.current = true;
|
||||
|
||||
const geometry = data.feature.getGeometry();
|
||||
if (!geometry) {
|
||||
isHandlingUpdateRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const geomType = geometry.getType();
|
||||
|
||||
switch (geomType) {
|
||||
case 'Polygon': {
|
||||
const polygonId = data.feature.get('polygonId');
|
||||
|
||||
if (polygonId) {
|
||||
const currentGeometry =
|
||||
form.current?.getFieldValue(ZoneFormField.PolygonGeometry) || [];
|
||||
const modifiedCoords = data.coordinates[0];
|
||||
const updatedGeometry = currentGeometry.map((item: any) =>
|
||||
item.id === polygonId
|
||||
? { ...item, geometry: modifiedCoords }
|
||||
: item,
|
||||
);
|
||||
|
||||
form.current?.setFieldsValue({
|
||||
[ZoneFormField.PolygonGeometry]: updatedGeometry,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'LineString': {
|
||||
form.current?.setFieldsValue({
|
||||
[ZoneFormField.PolylineData]: JSON.stringify(data.coordinates),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'Circle': {
|
||||
form.current?.setFieldsValue({
|
||||
[ZoneFormField.CircleData]: {
|
||||
center: data.coordinates.center,
|
||||
// radius: data.coordinates.radius,
|
||||
area: getAreaFromRadius(data.coordinates.radius),
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isHandlingUpdateRef.current = false;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Register event listeners
|
||||
baseMap.current?.onFeatureDrawn(handleFeatureDrawn);
|
||||
baseMap.current?.onFeatureModified(handleFeatureModified);
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
// Note: BaseMap doesn't have off method, so events will remain
|
||||
// This is acceptable as the component unmounts
|
||||
};
|
||||
}, [baseMap, enabled]);
|
||||
|
||||
/**
|
||||
* Update map display based on current form data
|
||||
*/
|
||||
const updateMapFromForm = (type: GeometryType) => {
|
||||
if (!baseMap || !form) return;
|
||||
const formCurrent = form.current;
|
||||
let geometryData;
|
||||
if (type === GeometryType.POLYGON) {
|
||||
geometryData =
|
||||
formCurrent?.getFieldValue(ZoneFormField.PolygonGeometry) || [];
|
||||
} else if (type === GeometryType.LINESTRING) {
|
||||
const polylineString =
|
||||
formCurrent?.getFieldValue(ZoneFormField.PolylineData) || [];
|
||||
geometryData = JSON.parse(polylineString);
|
||||
} else if (type === GeometryType.CIRCLE) {
|
||||
geometryData = formCurrent?.getFieldValue(ZoneFormField.CircleData)
|
||||
? [formCurrent?.getFieldValue(ZoneFormField.CircleData)]
|
||||
: [];
|
||||
}
|
||||
|
||||
if (!geometryData || geometryData.length === 0) {
|
||||
baseMap.current?.clearFeatures(dataLayerId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Nếu đang xử lý event từ Map (vẽ/sửa), KHÔNG clear và vẽ lại
|
||||
// vì Draw interaction đã add feature vào map sẵn rồi
|
||||
if (isHandlingUpdateRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing features trước khi vẽ lại (chỉ khi không đang handle map event)
|
||||
baseMap.current?.clearFeatures(dataLayerId);
|
||||
|
||||
switch (type) {
|
||||
case GeometryType.POLYGON:
|
||||
{
|
||||
const features: Feature[] = [];
|
||||
geometryData.forEach((geom: any) => {
|
||||
const feature = baseMap.current?.addPolygon(
|
||||
dataLayerId,
|
||||
[geom.geometry],
|
||||
DEFAULT_ZONE_DATA,
|
||||
geom.id,
|
||||
);
|
||||
features.push(feature!);
|
||||
});
|
||||
baseMap.current?.zoomToFeatures(features);
|
||||
}
|
||||
break;
|
||||
case GeometryType.LINESTRING:
|
||||
{
|
||||
const feature = baseMap.current?.addPolyline(
|
||||
dataLayerId,
|
||||
geometryData,
|
||||
DEFAULT_ZONE_DATA,
|
||||
);
|
||||
baseMap.current?.zoomToFeatures([feature!]);
|
||||
}
|
||||
break;
|
||||
case GeometryType.CIRCLE:
|
||||
{
|
||||
const feature = baseMap.current?.addCircle(
|
||||
dataLayerId,
|
||||
geometryData[0].center,
|
||||
getCircleRadius(geometryData[0].area),
|
||||
DEFAULT_ZONE_DATA,
|
||||
);
|
||||
baseMap.current?.zoomToFeatures([feature!]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear all drawn features from map
|
||||
*/
|
||||
const clearMapFeatures = () => {
|
||||
if (!baseMap) return;
|
||||
baseMap.current?.clearFeatures(dataLayerId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Draw existing geometry on map (for update mode)
|
||||
*/
|
||||
const drawExistingGeometry = (geometry: any) => {
|
||||
if (!baseMap || !geometry) return;
|
||||
|
||||
clearMapFeatures();
|
||||
|
||||
if (geometry.geom_type === GeometryType.POLYGON && geometry.polygons) {
|
||||
geometry.polygons.forEach((polygonCoords: number[][]) => {
|
||||
baseMap.current?.addPolygon(
|
||||
dataLayerId,
|
||||
[polygonCoords],
|
||||
DEFAULT_ZONE_DATA,
|
||||
);
|
||||
});
|
||||
} else if (
|
||||
geometry.geom_type === GeometryType.LINESTRING &&
|
||||
geometry.coordinates
|
||||
) {
|
||||
baseMap.current?.addPolyline(
|
||||
dataLayerId,
|
||||
geometry.coordinates,
|
||||
DEFAULT_ZONE_DATA,
|
||||
);
|
||||
} else if (
|
||||
geometry.geom_type === GeometryType.CIRCLE &&
|
||||
geometry.center &&
|
||||
geometry.radius
|
||||
) {
|
||||
baseMap.current?.addCircle(
|
||||
dataLayerId,
|
||||
geometry.center,
|
||||
geometry.radius,
|
||||
DEFAULT_ZONE_DATA,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
updateMapFromForm,
|
||||
clearMapFeatures,
|
||||
drawExistingGeometry,
|
||||
};
|
||||
};
|
||||
|
||||
export default useMapGeometrySync;
|
||||
520
src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/index.tsx
Normal file
@@ -0,0 +1,520 @@
|
||||
import { SGW_ROUTE_BANZONES_LIST } from '@/constants/slave/sgw/routes';
|
||||
import VietNamMap, {
|
||||
VietNamMapRef,
|
||||
} from '@/pages/Slave/SGW/Map/components/VietNamMap';
|
||||
import { BaseMap, DATA_LAYER } from '@/pages/Slave/SGW/Map/type';
|
||||
import {
|
||||
apiCreateBanzone,
|
||||
apiGetZoneById,
|
||||
apiUpdateBanzone,
|
||||
} from '@/services/slave/sgw/ZoneController';
|
||||
import {
|
||||
getAreaFromRadius,
|
||||
getCircleRadius,
|
||||
} from '@/utils/slave/sgw/geomUtils';
|
||||
import { DeleteOutlined, EditFilled, GatewayOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
PageContainer,
|
||||
ProCard,
|
||||
ProForm,
|
||||
ProFormInstance,
|
||||
} from '@ant-design/pro-components';
|
||||
import {
|
||||
FormattedMessage,
|
||||
history,
|
||||
useIntl,
|
||||
useLocation,
|
||||
useModel,
|
||||
useParams,
|
||||
} from '@umijs/max';
|
||||
import { Button, Flex, Form, Grid, message } from 'antd';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import ZoneForm from './components/ZoneForm';
|
||||
import { useMapGeometrySync } from './hooks';
|
||||
import {
|
||||
AreaCondition,
|
||||
checkValidateGeometry,
|
||||
DrawActionType,
|
||||
formatLineStringWKT,
|
||||
formatPolygonGeometryToWKT,
|
||||
GeometryType,
|
||||
parseLineStringWKT,
|
||||
parseMultiPolygonWKT,
|
||||
parsePointWKT,
|
||||
PolygonGeometry,
|
||||
ZoneFormData,
|
||||
ZoneFormField,
|
||||
ZoneLocationState,
|
||||
} from './type';
|
||||
const CreateOrUpdateBanzone = () => {
|
||||
const location = useLocation() as { state: ZoneLocationState };
|
||||
const shape = location.state?.shape;
|
||||
const type = location.state?.type;
|
||||
const { id: zoneId } = useParams();
|
||||
const intl = useIntl();
|
||||
const { useBreakpoint } = Grid;
|
||||
const screens = useBreakpoint();
|
||||
const formRef = useRef<ProFormInstance<ZoneFormData>>();
|
||||
const vietNamMapRef = useRef<VietNamMapRef>(null);
|
||||
const baseMap = useRef<BaseMap | null>(null);
|
||||
const drawController = useRef<any>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formReady, setFormReady] = useState(false);
|
||||
const { groupMap } = useModel('master.useGroups');
|
||||
const [mapActions, setMapActions] = useState<DrawActionType>({
|
||||
isDrawing: false,
|
||||
isModifying: false,
|
||||
});
|
||||
|
||||
const { clearMapFeatures, updateMapFromForm } = useMapGeometrySync({
|
||||
baseMap: baseMap,
|
||||
dataLayerId: DATA_LAYER,
|
||||
form: formRef,
|
||||
shape,
|
||||
enabled: !!baseMap.current,
|
||||
});
|
||||
|
||||
// Handler for back navigation
|
||||
const handleBack = () => {
|
||||
history.push(SGW_ROUTE_BANZONES_LIST);
|
||||
formRef.current?.resetFields();
|
||||
};
|
||||
|
||||
const handleMapReady = useCallback((baseMapInstance: BaseMap) => {
|
||||
baseMap.current = baseMapInstance;
|
||||
// Create a vector layer for dynamic features
|
||||
const vectorDataLayer = new VectorLayer({
|
||||
source: new VectorSource(),
|
||||
});
|
||||
vectorDataLayer.set('id', DATA_LAYER);
|
||||
baseMapInstance.addLayer(vectorDataLayer);
|
||||
baseMapInstance.setView([116.152685, 15.70581], 5);
|
||||
// Initialize draw controller
|
||||
drawController.current = baseMapInstance.DrawAndModifyFeature(DATA_LAYER);
|
||||
}, []);
|
||||
|
||||
const handleEnableModify = () => {
|
||||
if (drawController.current) {
|
||||
setMapActions({
|
||||
isDrawing: false,
|
||||
isModifying: true,
|
||||
});
|
||||
drawController.current.removeInteractions();
|
||||
drawController.current.enableModify();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values?: ZoneFormData) => {
|
||||
if (!values) return;
|
||||
// Validate required fields
|
||||
if (!values[ZoneFormField.AreaName]) {
|
||||
message.error(
|
||||
intl.formatMessage({ id: 'banzone.form.name.error.required' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!values[ZoneFormField.AreaType]) {
|
||||
message.error(
|
||||
intl.formatMessage({ id: 'banzone.form.name.area_type.required' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!values[ZoneFormField.AreaId]) {
|
||||
message.error(
|
||||
intl.formatMessage({ id: 'banzone.form.name.province.required' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const lineStringData = values[ZoneFormField.PolylineData];
|
||||
const polygonData = values[ZoneFormField.PolygonGeometry];
|
||||
const circleData = values[ZoneFormField.CircleData];
|
||||
|
||||
if (!lineStringData && !polygonData && !circleData) {
|
||||
message.error(
|
||||
intl.formatMessage({ id: 'banzone.form.name.geometry.required' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let geometryJson = '';
|
||||
|
||||
// // Convert geometry to API format
|
||||
switch (shape) {
|
||||
case GeometryType.POLYGON: {
|
||||
const polygonWKT: string = formatPolygonGeometryToWKT(
|
||||
polygonData || [],
|
||||
);
|
||||
geometryJson = JSON.stringify({
|
||||
geom_type: GeometryType.POLYGON,
|
||||
geom_poly: polygonWKT,
|
||||
geom_lines: '',
|
||||
geom_point: '',
|
||||
geom_radius: 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case GeometryType.LINESTRING: {
|
||||
const polylineData = JSON.parse(lineStringData || '[]');
|
||||
const polylineWKT: string = formatLineStringWKT(polylineData);
|
||||
geometryJson = JSON.stringify({
|
||||
geom_type: GeometryType.LINESTRING,
|
||||
geom_poly: '',
|
||||
geom_lines: polylineWKT,
|
||||
geom_point: '',
|
||||
geom_radius: 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case GeometryType.CIRCLE: {
|
||||
// For circle, API expects radius in hecta, convert from meters
|
||||
const radiusInMetter = getCircleRadius(circleData?.area || 0);
|
||||
const pointWKT = `POINT(${circleData?.center[0] || 0} ${
|
||||
circleData?.center[1] || 0
|
||||
})`;
|
||||
geometryJson = JSON.stringify({
|
||||
geom_type: GeometryType.CIRCLE,
|
||||
geom_poly: '',
|
||||
geom_lines: '',
|
||||
geom_point: pointWKT,
|
||||
geom_radius: radiusInMetter,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
message.error('Loại hình học không hợp lệ!');
|
||||
return;
|
||||
}
|
||||
const groupId = values[ZoneFormField.AreaId];
|
||||
const provinceCode = Array.isArray(groupId)
|
||||
? groupId.map((id) => groupMap[id]?.code || '').join(',')
|
||||
: groupMap[groupId as string]?.code || '';
|
||||
// // Prepare request body
|
||||
const requestBody: SgwModel.ZoneBodyRequest = {
|
||||
name: values[ZoneFormField.AreaName],
|
||||
type: values[ZoneFormField.AreaType],
|
||||
group_id: groupId as string,
|
||||
province_code: provinceCode,
|
||||
enabled: values[ZoneFormField.AreaEnabled] ?? true,
|
||||
description: values[ZoneFormField.AreaDescription],
|
||||
conditions: values[ZoneFormField.AreaConditions] as SgwModel.Condition[],
|
||||
geom: geometryJson,
|
||||
};
|
||||
|
||||
console.log('Submit body:', requestBody);
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
if (type === 'create') {
|
||||
const key = 'create';
|
||||
message.open({
|
||||
key,
|
||||
type: 'loading',
|
||||
content: intl.formatMessage({ id: 'banzone.creating' }),
|
||||
});
|
||||
await apiCreateBanzone(requestBody);
|
||||
message.open({
|
||||
key,
|
||||
type: 'success',
|
||||
content: intl.formatMessage({ id: 'banzone.creating_success' }),
|
||||
});
|
||||
} else if (type === 'update' && zoneId) {
|
||||
const key = 'update';
|
||||
message.open({
|
||||
key,
|
||||
type: 'loading',
|
||||
content: intl.formatMessage({ id: 'banzone.updating' }),
|
||||
});
|
||||
await apiUpdateBanzone(zoneId, requestBody);
|
||||
message.open({
|
||||
key,
|
||||
type: 'success',
|
||||
content: intl.formatMessage({ id: 'banzone.updating_success' }),
|
||||
});
|
||||
} else {
|
||||
message.error(
|
||||
type === 'update'
|
||||
? intl.formatMessage({ id: 'banzone.updating_fail' })
|
||||
: intl.formatMessage({ id: 'banzone.creating_fail' }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
handleBack();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error('Failed to save zone:', error);
|
||||
message.error(intl.formatMessage({ id: 'banzone.fail.save' }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnClickDraw = () => {
|
||||
drawController.current = baseMap.current?.DrawAndModifyFeature(DATA_LAYER);
|
||||
setMapActions({
|
||||
isDrawing: true,
|
||||
isModifying: false,
|
||||
});
|
||||
drawController.current.removeInteractions();
|
||||
switch (shape) {
|
||||
case 1:
|
||||
drawController.current.drawPolygon();
|
||||
break;
|
||||
case 2:
|
||||
drawController.current.drawLineString();
|
||||
break;
|
||||
case 3:
|
||||
drawController.current.drawCircle();
|
||||
break;
|
||||
case 4:
|
||||
drawController.current.drawPoint();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const polygonGeometry =
|
||||
(Form.useWatch(
|
||||
ZoneFormField.PolygonGeometry,
|
||||
formReady && formRef.current ? formRef.current : undefined,
|
||||
) as PolygonGeometry[]) || [];
|
||||
|
||||
const polylineData = Form.useWatch(
|
||||
ZoneFormField.PolylineData,
|
||||
formReady && formRef.current ? formRef.current : undefined,
|
||||
);
|
||||
|
||||
const circleData = Form.useWatch(
|
||||
ZoneFormField.CircleData,
|
||||
formReady && formRef.current ? formRef.current : undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (polygonGeometry && polygonGeometry.length > 0) {
|
||||
updateMapFromForm(GeometryType.POLYGON);
|
||||
}
|
||||
}, [polygonGeometry, formRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (polylineData && checkValidateGeometry(polylineData)) {
|
||||
updateMapFromForm(GeometryType.LINESTRING);
|
||||
}
|
||||
}, [polylineData, formRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (circleData) {
|
||||
updateMapFromForm(GeometryType.CIRCLE);
|
||||
}
|
||||
}, [circleData, formRef]);
|
||||
return (
|
||||
<PageContainer
|
||||
onBack={handleBack}
|
||||
title={
|
||||
type === 'create' ? (
|
||||
<FormattedMessage id="banzones.create" />
|
||||
) : (
|
||||
<FormattedMessage id="banzones.update" />
|
||||
)
|
||||
}
|
||||
style={{
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 10, lg: 10, xl: 10 }}>
|
||||
<ProForm<ZoneFormData>
|
||||
formRef={formRef}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
onInit={(_, form) => {
|
||||
formRef.current = form;
|
||||
setFormReady(true);
|
||||
}}
|
||||
initialValues={{
|
||||
[ZoneFormField.AreaEnabled]: true,
|
||||
}}
|
||||
request={async () => {
|
||||
if (type === 'update' && zoneId) {
|
||||
try {
|
||||
const zone: SgwModel.Banzone = await apiGetZoneById(zoneId);
|
||||
if (!zone) return {} as ZoneFormData;
|
||||
// Parse geometry (API may use `geom` or `geometry` field)
|
||||
let parsedGeometry: SgwModel.Geom | undefined;
|
||||
const geomRaw = (zone as any).geom ?? (zone as any).geometry;
|
||||
if (geomRaw) {
|
||||
try {
|
||||
parsedGeometry =
|
||||
typeof geomRaw === 'string'
|
||||
? JSON.parse(geomRaw)
|
||||
: geomRaw;
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse geometry', e);
|
||||
}
|
||||
}
|
||||
const groupId = Object.entries(groupMap).find(
|
||||
([, value]) => value.code === zone.province_code,
|
||||
)?.[0] as string | undefined;
|
||||
// Build base form data
|
||||
const formData: Partial<ZoneFormData> = {
|
||||
[ZoneFormField.AreaName]: zone.name || '',
|
||||
[ZoneFormField.AreaType]: zone.type ?? 1,
|
||||
[ZoneFormField.AreaEnabled]: zone.enabled ?? true,
|
||||
[ZoneFormField.AreaDescription]: zone.description || '',
|
||||
[ZoneFormField.AreaId]: groupId || '',
|
||||
[ZoneFormField.AreaConditions]:
|
||||
zone.conditions as AreaCondition[],
|
||||
};
|
||||
// Map geometry to form fields depending on geometry type
|
||||
if (parsedGeometry) {
|
||||
switch (parsedGeometry.geom_type) {
|
||||
case GeometryType.POLYGON: {
|
||||
const polygons = parseMultiPolygonWKT(
|
||||
parsedGeometry.geom_poly || '',
|
||||
);
|
||||
formData[ZoneFormField.PolygonGeometry] = polygons.map(
|
||||
(polygon) => ({
|
||||
geometry: polygon,
|
||||
id: Math.random().toString(36).substring(2, 9),
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case GeometryType.LINESTRING: {
|
||||
const polyline = parseLineStringWKT(
|
||||
parsedGeometry.geom_lines || '',
|
||||
);
|
||||
formData[ZoneFormField.PolylineData] =
|
||||
JSON.stringify(polyline);
|
||||
break;
|
||||
}
|
||||
|
||||
case GeometryType.CIRCLE: {
|
||||
const center = parsePointWKT(
|
||||
parsedGeometry.geom_point || '',
|
||||
);
|
||||
formData[ZoneFormField.CircleData] = {
|
||||
center: center || [0, 0],
|
||||
radius: parsedGeometry.geom_radius || 0,
|
||||
area: getAreaFromRadius(
|
||||
parsedGeometry.geom_radius || 0,
|
||||
),
|
||||
};
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return formData as ZoneFormData;
|
||||
} catch (error) {
|
||||
console.error('Failed to load zone for request:', error);
|
||||
return {} as ZoneFormData;
|
||||
}
|
||||
}
|
||||
return {} as ZoneFormData;
|
||||
}}
|
||||
submitter={{
|
||||
searchConfig: {
|
||||
submitText:
|
||||
type === 'create'
|
||||
? intl.formatMessage({ id: 'banzone.create.button.title' })
|
||||
: intl.formatMessage({ id: 'banzone.update.button.title' }),
|
||||
},
|
||||
submitButtonProps: {
|
||||
loading: loading,
|
||||
},
|
||||
render: (_, dom) => {
|
||||
return (
|
||||
<Flex
|
||||
gap={8}
|
||||
justify="center"
|
||||
style={{ marginTop: 24, marginBottom: 24 }}
|
||||
>
|
||||
{dom}
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<ZoneForm formRef={formRef} shape={shape} />
|
||||
<Flex align="center" gap={8} justify="end">
|
||||
<Button
|
||||
onClick={handleOnClickDraw}
|
||||
variant={`${mapActions.isDrawing ? 'solid' : 'dashed'}`}
|
||||
color={`${mapActions.isDrawing ? 'green' : 'default'}`}
|
||||
icon={<GatewayOutlined />}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="banzone.map_action.draw"
|
||||
defaultMessage="Draw"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleEnableModify}
|
||||
variant={`${mapActions.isModifying ? 'solid' : 'dashed'}`}
|
||||
color={`${mapActions.isModifying ? 'green' : 'default'}`}
|
||||
icon={<EditFilled />}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="banzone.map_action.modify"
|
||||
defaultMessage="Modify"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
type="primary"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => {
|
||||
clearMapFeatures();
|
||||
setMapActions({
|
||||
isDrawing: false,
|
||||
isModifying: false,
|
||||
});
|
||||
switch (shape) {
|
||||
case GeometryType.POLYGON:
|
||||
formRef.current?.setFieldValue(
|
||||
ZoneFormField.PolygonGeometry,
|
||||
[],
|
||||
);
|
||||
break;
|
||||
case GeometryType.LINESTRING:
|
||||
formRef.current?.setFieldValue(
|
||||
ZoneFormField.PolylineData,
|
||||
'',
|
||||
);
|
||||
break;
|
||||
case GeometryType.CIRCLE:
|
||||
formRef.current?.setFieldValue(
|
||||
ZoneFormField.CircleData,
|
||||
undefined,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}}
|
||||
></Button>
|
||||
</Flex>
|
||||
</ProForm>
|
||||
</ProCard>
|
||||
<ProCard ghost colSpan={{ xs: 24, sm: 24, md: 14, lg: 14, xl: 14 }}>
|
||||
<VietNamMap
|
||||
ref={vietNamMapRef}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
}}
|
||||
onMapReady={handleMapReady}
|
||||
/>
|
||||
</ProCard>
|
||||
</ProCard>
|
||||
</PageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateOrUpdateBanzone;
|
||||
216
src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/type.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
export enum ZoneFormField {
|
||||
AreaName = 'name',
|
||||
AreaType = 'type',
|
||||
AreaId = 'id',
|
||||
AreaEnabled = 'enabled',
|
||||
AreaDescription = 'description',
|
||||
AreaConditions = 'conditions',
|
||||
AreaProvinceCode = 'province_code',
|
||||
PolygonGeometry = 'geometry',
|
||||
PolylineData = 'polyline_data',
|
||||
CircleData = 'circle_data',
|
||||
}
|
||||
|
||||
export interface ZoneFormData {
|
||||
[ZoneFormField.AreaName]: string;
|
||||
[ZoneFormField.AreaType]: number; // API returns number
|
||||
[ZoneFormField.AreaId]: string | string[] | null;
|
||||
[ZoneFormField.AreaEnabled]: boolean;
|
||||
[ZoneFormField.AreaDescription]?: string;
|
||||
[ZoneFormField.AreaConditions]?: AreaCondition[];
|
||||
[ZoneFormField.AreaProvinceCode]: string | number; // API returns string
|
||||
[ZoneFormField.PolygonGeometry]: PolygonGeometry[];
|
||||
[ZoneFormField.PolylineData]?: string;
|
||||
[ZoneFormField.CircleData]?: CircleGeometry;
|
||||
}
|
||||
|
||||
type MonthRangeCondition = {
|
||||
type: 'month_range';
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
type DateRangeCondition = {
|
||||
type: 'date_range';
|
||||
from: string;
|
||||
to: string;
|
||||
};
|
||||
|
||||
type LengthLimitCondition = {
|
||||
type: 'length_limit';
|
||||
min: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
export type AreaCondition =
|
||||
| MonthRangeCondition
|
||||
| DateRangeCondition
|
||||
| LengthLimitCondition;
|
||||
|
||||
export interface ZoneLocationState {
|
||||
shape?: number;
|
||||
type: 'create' | 'update';
|
||||
}
|
||||
|
||||
export const tagPlusStyle = {
|
||||
height: 22,
|
||||
// background: "blue",
|
||||
borderStyle: 'dashed',
|
||||
};
|
||||
|
||||
export type PolygonGeometry = {
|
||||
geometry: number[][];
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type CircleGeometry = {
|
||||
center: [number, number];
|
||||
radius: number;
|
||||
area?: number;
|
||||
};
|
||||
|
||||
// Geometry type mapping with API
|
||||
export enum GeometryType {
|
||||
POLYGON = 1,
|
||||
LINESTRING = 2,
|
||||
CIRCLE = 3,
|
||||
}
|
||||
|
||||
export interface DrawActionType {
|
||||
isDrawing: boolean;
|
||||
isModifying: boolean;
|
||||
}
|
||||
|
||||
// Parse MULTIPOINT/WKT for polygon
|
||||
export const parseMultiPolygonWKT = (wktString: string): number[][][] => {
|
||||
if (
|
||||
!wktString ||
|
||||
typeof wktString !== 'string' ||
|
||||
!wktString.startsWith('MULTIPOLYGON')
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const matched = wktString.match(/MULTIPOLYGON\s*\(\(\((.*)\)\)\)/);
|
||||
if (!matched) return [];
|
||||
|
||||
const polygons = matched[1]
|
||||
.split(')),((') // chia các polygon
|
||||
.map((polygonStr) =>
|
||||
polygonStr
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((coordStr) => {
|
||||
const [x, y] = coordStr.trim().split(' ').map(Number);
|
||||
return [x, y];
|
||||
}),
|
||||
);
|
||||
return polygons;
|
||||
};
|
||||
|
||||
// // Parse LINESTRING WKT
|
||||
export const parseLineStringWKT = (wkt: string): number[][] => {
|
||||
if (!wkt || !wkt.startsWith('LINESTRING')) return [];
|
||||
|
||||
const match = wkt.match(/LINESTRING\s*\((.*)\)/);
|
||||
if (!match) return [];
|
||||
|
||||
return match[1].split(',').map((coordStr) => {
|
||||
const [x, y] = coordStr.trim().split(' ').map(Number);
|
||||
return [x, y]; // [lng, lat]
|
||||
});
|
||||
};
|
||||
|
||||
// // Parse POINT WKT
|
||||
export const parsePointWKT = (wkt: string): [number, number] | null => {
|
||||
if (!wkt || !wkt.startsWith('POINT')) return null;
|
||||
|
||||
const match = wkt.match(/POINT\s*\(([-\d.]+)\s+([-\d.]+)\)/);
|
||||
if (!match) return null;
|
||||
|
||||
return [parseFloat(match[1]), parseFloat(match[2])]; // [lng, lat]
|
||||
};
|
||||
|
||||
// Format coordinates array to WKT LINESTRING
|
||||
export const formatLineStringWKT = (coordinates: number[][]): string => {
|
||||
const coordStr = coordinates.map((c) => `${c[0]} ${c[1]}`).join(',');
|
||||
return `LINESTRING(${coordStr})`;
|
||||
};
|
||||
|
||||
// Format AreaGeometry[] to WKT MULTIPOLYGON string
|
||||
export const formatPolygonGeometryToWKT = (
|
||||
geometries: PolygonGeometry[],
|
||||
): string => {
|
||||
const polygons = geometries.map((g) => g.geometry as number[][]);
|
||||
|
||||
if (polygons.length === 0) return '';
|
||||
|
||||
const polygonStrs = polygons.map((polygon) => {
|
||||
const coordStr = polygon.map((c) => `${c[0]} ${c[1]}`).join(',');
|
||||
return `(${coordStr})`;
|
||||
});
|
||||
|
||||
return `MULTIPOLYGON((${polygonStrs.join('),(')}))`;
|
||||
};
|
||||
|
||||
export const validateGeometry = (value: any) => {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return Promise.reject('Dữ liệu không hợp lệ!');
|
||||
}
|
||||
try {
|
||||
const text = value.trim();
|
||||
const formattedText = text.startsWith('[[') ? text : `[${text}]`;
|
||||
const data = JSON.parse(formattedText);
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
return Promise.reject('Dữ liệu không phải mảng!');
|
||||
}
|
||||
|
||||
for (const item of data) {
|
||||
if (
|
||||
!Array.isArray(item) ||
|
||||
item.length !== 2 ||
|
||||
typeof item[0] !== 'number' ||
|
||||
typeof item[1] !== 'number'
|
||||
) {
|
||||
return Promise.reject(
|
||||
'Mỗi dòng phải là [longitude, latitude] với số thực!',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
} catch (e) {
|
||||
return Promise.reject('Định dạng JSON không hợp lệ!');
|
||||
}
|
||||
};
|
||||
|
||||
export const checkValidateGeometry = (value: string) => {
|
||||
if (!value || typeof value !== 'string') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const text = value.trim();
|
||||
const formattedText = text.startsWith('[[') ? text : `[${text}]`;
|
||||
const data = JSON.parse(formattedText);
|
||||
|
||||
if (!Array.isArray(data)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const item of data) {
|
||||
if (
|
||||
!Array.isArray(item) ||
|
||||
item.length !== 2 ||
|
||||
typeof item[0] !== 'number' ||
|
||||
typeof item[1] !== 'number'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,644 @@
|
||||
import React from 'react';
|
||||
import IconFont from '@/components/IconFont';
|
||||
import TreeGroup from '@/components/shared/TreeGroup';
|
||||
import { DEFAULT_PAGE_SIZE } from '@/const';
|
||||
import {
|
||||
SGW_ROUTE_BANZONES,
|
||||
SGW_ROUTE_BANZONES_LIST,
|
||||
} from '@/constants/slave/sgw/routes';
|
||||
|
||||
import {
|
||||
apiGetAllBanzones,
|
||||
apiRemoveBanzone,
|
||||
} from '@/services/slave/sgw/ZoneController';
|
||||
import { flattenGroupNodes } from '@/utils/slave/sgw/groupUtils';
|
||||
import { formatDate } from '@/utils/slave/sgw/timeUtils';
|
||||
import { DeleteOutlined, DownOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
ActionType,
|
||||
ProCard,
|
||||
ProColumns,
|
||||
ProTable,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, history, useIntl, useModel } from '@umijs/max';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Grid,
|
||||
message,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { MenuProps } from 'antd/lib';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
const BanZoneList = () => {
|
||||
const { useBreakpoint } = Grid;
|
||||
const intl = useIntl();
|
||||
const screens = useBreakpoint();
|
||||
const tableRef = useRef<ActionType>();
|
||||
const [groupCheckedKeys, setGroupCheckedKeys] = useState<string>('');
|
||||
const { groups, getGroups } = useModel('master.useGroups');
|
||||
const groupFlattened = flattenGroupNodes(groups || []);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [selectedRowsState, setSelectedRows] = useState<SgwModel.Banzone[]>([]);
|
||||
useEffect(() => {
|
||||
if (groups === null) {
|
||||
getGroups();
|
||||
}
|
||||
}, [groups]);
|
||||
|
||||
// Reload table khi groups được load
|
||||
useEffect(() => {
|
||||
if (groups && groups.length > 0 && tableRef.current) {
|
||||
tableRef.current.reload();
|
||||
}
|
||||
}, [groups]);
|
||||
|
||||
const handleEdit = (record: SgwModel.Banzone) => {
|
||||
console.log('record: ', record);
|
||||
let geomType = 1; // Default: Polygon
|
||||
try {
|
||||
if (record.geometry) {
|
||||
const geometry: SgwModel.Geom = JSON.parse(record.geometry);
|
||||
geomType = geometry.geom_type || 1;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse geometry:', e);
|
||||
}
|
||||
history.push(`${SGW_ROUTE_BANZONES_LIST}/${record.id}`, {
|
||||
type: 'update',
|
||||
shape: geomType,
|
||||
});
|
||||
};
|
||||
const handleDelete = async (record: SgwModel.Banzone) => {
|
||||
try {
|
||||
const groupID = groupFlattened.find(
|
||||
(m) => m.metadata.code === record.province_code,
|
||||
)?.id;
|
||||
await apiRemoveBanzone(record.id || '', groupID || '');
|
||||
messageApi.success(
|
||||
intl.formatMessage({
|
||||
id: 'banzone.notify.delete_zone_success',
|
||||
defaultMessage: 'Zone deleted successfully',
|
||||
}),
|
||||
);
|
||||
// Reload lại bảng
|
||||
if (tableRef.current) {
|
||||
tableRef.current.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting area:', error);
|
||||
messageApi.error(
|
||||
intl.formatMessage({
|
||||
id: 'banzone.notify.fail',
|
||||
defaultMessage: 'Delete zone failed!',
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
const columns: ProColumns<SgwModel.Banzone>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: <FormattedMessage id="banzones.name" defaultMessage="Name" />,
|
||||
dataIndex: 'name',
|
||||
render: (_, record) => (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
<Paragraph
|
||||
copyable
|
||||
style={{ margin: 0 }}
|
||||
ellipsis={{ rows: 999, tooltip: record?.name }}
|
||||
>
|
||||
{record?.name}
|
||||
</Paragraph>
|
||||
</div>
|
||||
),
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
key: 'group',
|
||||
title: <FormattedMessage id="banzones.area" defaultMessage="Province" />,
|
||||
dataIndex: 'province_code',
|
||||
hideInSearch: true,
|
||||
responsive: ['lg', 'md'],
|
||||
ellipsis: true,
|
||||
render: (_, record) => {
|
||||
const matchedMember =
|
||||
groupFlattened.find(
|
||||
(group) => group.metadata.code === record.province_code,
|
||||
) ?? null;
|
||||
return (
|
||||
<Text ellipsis={{ tooltip: matchedMember?.name || '-' }}>
|
||||
{matchedMember?.name || '-'}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
title: (
|
||||
<FormattedMessage id="banzones.description" defaultMessage="Mô tả" />
|
||||
),
|
||||
dataIndex: 'description',
|
||||
hideInSearch: true,
|
||||
render: (_, record) => (
|
||||
<Paragraph
|
||||
ellipsis={{ rows: 2, tooltip: record?.description }}
|
||||
style={{
|
||||
margin: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{record?.description || '-'}
|
||||
</Paragraph>
|
||||
),
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
title: <FormattedMessage id="banzones.type" defaultMessage="Loại" />,
|
||||
dataIndex: 'type',
|
||||
valueType: 'select',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.area.fishing_ban',
|
||||
defaultMessage: 'Fishing Ban',
|
||||
}),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.area.move_ban',
|
||||
defaultMessage: 'Movement Ban',
|
||||
}),
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.area.safe',
|
||||
defaultMessage: 'Safe Area',
|
||||
}),
|
||||
value: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
render: (_, record) => (
|
||||
<Tag color={record.type === 1 ? '#f50' : 'orange'}>
|
||||
{record.type === 1
|
||||
? intl.formatMessage({
|
||||
id: 'banzone.area.fishing_ban',
|
||||
defaultMessage: 'Fishing Ban',
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: 'banzone.area.move_ban',
|
||||
defaultMessage: 'Movement Ban',
|
||||
})}
|
||||
</Tag>
|
||||
),
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
key: 'conditions',
|
||||
title: (
|
||||
<FormattedMessage id="banzones.conditions" defaultMessage="Điều kiện" />
|
||||
),
|
||||
dataIndex: 'conditions',
|
||||
hideInSearch: true,
|
||||
render: (conditions) => {
|
||||
if (!Array.isArray(conditions)) return null;
|
||||
return (
|
||||
<Space direction="vertical" size={4}>
|
||||
{conditions.map((cond, index) => {
|
||||
switch (cond.type) {
|
||||
case 'month_range':
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
title={`Áp dụng từ tháng ${cond.from} đến tháng ${cond.to} hàng năm`}
|
||||
>
|
||||
<Tag
|
||||
color="geekblue"
|
||||
style={{ borderRadius: 8, margin: 0 }}
|
||||
>
|
||||
Th.{cond.from} - Th.{cond.to}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'date_range':
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
title={`Áp dụng từ ${formatDate(
|
||||
cond.from,
|
||||
)} đến ${formatDate(cond.to)}`}
|
||||
>
|
||||
<Tag color="green" style={{ borderRadius: 8, margin: 0 }}>
|
||||
{formatDate(cond.from)} → {formatDate(cond.to)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'length_limit':
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
title={`Tàu từ ${cond.min} đến ${cond.max} mét`}
|
||||
>
|
||||
<Tag color="cyan" style={{ borderRadius: 8, margin: 0 }}>
|
||||
{cond.min}-{cond.max}m
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
title: (
|
||||
<FormattedMessage id="banzones.state" defaultMessage="Trạng thái" />
|
||||
),
|
||||
dataIndex: 'enabled',
|
||||
valueType: 'select',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.is_enable',
|
||||
defaultMessage: 'Enabled',
|
||||
}),
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.is_unenabled',
|
||||
defaultMessage: 'Disabled',
|
||||
}),
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
hideInSearch: false,
|
||||
responsive: ['lg', 'md'],
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<Tag color={record.enabled === true ? '#08CB00' : '#DCDCDC'}>
|
||||
{record.enabled === true
|
||||
? intl.formatMessage({
|
||||
id: 'banzone.is_enable',
|
||||
defaultMessage: 'Enabled',
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: 'banzone.is_unenabled',
|
||||
defaultMessage: 'Disabled',
|
||||
})}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: <FormattedMessage id="banzones.action" defaultMessage="Action" />,
|
||||
hideInSearch: true,
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
render: (_, record) => [
|
||||
<Space key="actions">
|
||||
<Button
|
||||
key="edit"
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleEdit(record)}
|
||||
></Button>
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title={intl.formatMessage({
|
||||
id: 'common.delete_confirm',
|
||||
defaultMessage: 'Confirm delete?',
|
||||
})}
|
||||
description={`${intl.formatMessage({
|
||||
id: 'banzone.notify.delete_zone_confirm',
|
||||
defaultMessage: 'Are you sure you want to delete this zone',
|
||||
})} "${record.name}"?`}
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText={intl.formatMessage({
|
||||
id: 'common.delete',
|
||||
defaultMessage: 'Delete',
|
||||
})}
|
||||
cancelText={intl.formatMessage({
|
||||
id: 'common.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
})}
|
||||
okType="danger"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
></Button>
|
||||
</Popconfirm>
|
||||
</Space>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const items: Required<MenuProps>['items'] = [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.polygon',
|
||||
defaultMessage: 'Polygon',
|
||||
}),
|
||||
onClick: () => {
|
||||
history.push(SGW_ROUTE_BANZONES, {
|
||||
shape: 1,
|
||||
type: 'create',
|
||||
});
|
||||
},
|
||||
key: '0',
|
||||
icon: <IconFont type="icon-polygon" />,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.polyline',
|
||||
defaultMessage: 'Polyline',
|
||||
}),
|
||||
key: '1',
|
||||
onClick: () => {
|
||||
history.push(SGW_ROUTE_BANZONES, {
|
||||
shape: 2,
|
||||
type: 'create',
|
||||
});
|
||||
},
|
||||
icon: <IconFont type="icon-polyline" />,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.circle',
|
||||
defaultMessage: 'Circle',
|
||||
}),
|
||||
key: '3',
|
||||
onClick: () => {
|
||||
history.push(SGW_ROUTE_BANZONES, {
|
||||
shape: 3,
|
||||
type: 'create',
|
||||
});
|
||||
},
|
||||
icon: <IconFont type="icon-circle" />,
|
||||
},
|
||||
];
|
||||
|
||||
const deleteMultipleBanzones = async (records: SgwModel.Banzone[]) => {
|
||||
const key = 'deleteMultiple';
|
||||
messageApi.open({
|
||||
key,
|
||||
type: 'loading',
|
||||
content: intl.formatMessage({
|
||||
id: 'common.deleting',
|
||||
defaultMessage: 'Deleting...',
|
||||
}),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
for (const record of records) {
|
||||
const groupID = groupFlattened.find(
|
||||
(m) => m.metadata.code === record.province_code,
|
||||
)?.id;
|
||||
|
||||
await apiRemoveBanzone(record.id || '', groupID || '');
|
||||
}
|
||||
messageApi.open({
|
||||
key,
|
||||
type: 'success',
|
||||
content: `Đã xoá thành công ${records.length} khu vực`,
|
||||
duration: 2,
|
||||
});
|
||||
tableRef.current?.reload();
|
||||
} catch (error) {
|
||||
console.error('Error deleting area:', error);
|
||||
messageApi.open({
|
||||
key,
|
||||
type: 'error',
|
||||
content: intl.formatMessage({
|
||||
id: 'banzone.notify.fail',
|
||||
defaultMessage: 'Delete zone failed!',
|
||||
}),
|
||||
duration: 2,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const SGWArea: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Khu vực (SGW Manager)</h1>
|
||||
</div>
|
||||
<>
|
||||
{contextHolder}
|
||||
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 4, lg: 4, xl: 4 }}>
|
||||
<TreeGroup
|
||||
multiple
|
||||
onSelected={(value) => {
|
||||
// Convert group IDs to province codes string
|
||||
const selectedIds = Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
? [value]
|
||||
: [];
|
||||
const provinceCodes =
|
||||
selectedIds.length > 0
|
||||
? selectedIds
|
||||
.reduce((codes: string[], id) => {
|
||||
const group = groupFlattened.find((g) => g.id === id);
|
||||
if (group?.metadata?.code) {
|
||||
codes.push(group.metadata.code);
|
||||
}
|
||||
return codes;
|
||||
}, [])
|
||||
.join(',')
|
||||
: '';
|
||||
|
||||
setGroupCheckedKeys(provinceCodes);
|
||||
tableRef.current?.reload();
|
||||
}}
|
||||
/>
|
||||
</ProCard>
|
||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 20, lg: 20, xl: 20 }}>
|
||||
<ProTable<SgwModel.Banzone>
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1000 }}
|
||||
actionRef={tableRef}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['5', '10', '15', '20'],
|
||||
showTotal: (total, range) =>
|
||||
`${range[0]}-${range[1]}
|
||||
${intl.formatMessage({
|
||||
id: 'common.of',
|
||||
defaultMessage: 'of',
|
||||
})}
|
||||
${total} ${intl.formatMessage({
|
||||
id: 'banzones.title',
|
||||
defaultMessage: 'zones',
|
||||
})}`,
|
||||
}}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, name, type, enabled } = params;
|
||||
|
||||
// Nếu chưa có groups, đợi
|
||||
if (!groups || groups.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
|
||||
|
||||
const groupFalttened = flattenGroupNodes(groups || []);
|
||||
const groupId =
|
||||
groupCheckedKeys ||
|
||||
groupFalttened
|
||||
.map((group) => group.metadata.code)
|
||||
.filter(Boolean)
|
||||
.join(',') + ',';
|
||||
|
||||
if (!groupId || groupId === ',') {
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const body: SgwModel.SearchZonePaginationBody = {
|
||||
name: name || '',
|
||||
order: 'name',
|
||||
dir: 'asc',
|
||||
limit: pageSize,
|
||||
offset: offset,
|
||||
metadata: {
|
||||
province_code: groupId,
|
||||
...(type ? { type: Number(type) } : {}), // nếu có type thì thêm vào
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await apiGetAllBanzones(body);
|
||||
return {
|
||||
success: true,
|
||||
data: resp?.banzones || [],
|
||||
total: resp?.total || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Query banzones failed:', error);
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
}}
|
||||
rowKey="id"
|
||||
options={{
|
||||
search: false,
|
||||
setting: false,
|
||||
density: false,
|
||||
reload: true,
|
||||
}}
|
||||
search={{
|
||||
layout: 'vertical',
|
||||
defaultCollapsed: false,
|
||||
}}
|
||||
dateFormatter="string"
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedRowsState?.map((row) => row?.id ?? ''),
|
||||
onChange: (_, selectedRows) => {
|
||||
setSelectedRows(selectedRows);
|
||||
},
|
||||
}}
|
||||
tableAlertRender={({ selectedRowKeys }) => (
|
||||
<div>Đã chọn {selectedRowKeys.length} mục</div>
|
||||
)}
|
||||
tableAlertOptionRender={({ selectedRows, onCleanSelected }) => {
|
||||
return (
|
||||
<Flex gap={5}>
|
||||
<Popconfirm
|
||||
title={intl.formatMessage({
|
||||
id: 'common.notification',
|
||||
defaultMessage: 'Thông báo',
|
||||
})}
|
||||
description={`Bạn muốn xoá hết ${selectedRows.length} khu vực này?`}
|
||||
onConfirm={() => {
|
||||
deleteMultipleBanzones(selectedRows);
|
||||
}}
|
||||
okText={intl.formatMessage({
|
||||
id: 'common.sure',
|
||||
defaultMessage: 'Chắc chắn',
|
||||
})}
|
||||
cancelText={intl.formatMessage({
|
||||
id: 'common.no',
|
||||
defaultMessage: 'Không',
|
||||
})}
|
||||
>
|
||||
<Button type="primary" danger>
|
||||
{intl.formatMessage({
|
||||
id: 'common.delete',
|
||||
defaultMessage: 'Xóa',
|
||||
})}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
|
||||
<Button color="cyan" variant="text" onClick={onCleanSelected}>
|
||||
{intl.formatMessage({
|
||||
id: 'common.cancel',
|
||||
defaultMessage: 'Bỏ chọn',
|
||||
})}
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Dropdown
|
||||
menu={{ items }}
|
||||
trigger={['click']}
|
||||
key="toolbar-dropdown"
|
||||
>
|
||||
<Button type="primary">
|
||||
<Space>
|
||||
{intl.formatMessage({
|
||||
id: 'banzones.create',
|
||||
defaultMessage: 'Tạo khu vực',
|
||||
})}
|
||||
<DownOutlined />
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>,
|
||||
]}
|
||||
/>
|
||||
</ProCard>
|
||||
</ProCard>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SGWArea;
|
||||
export default BanZoneList;
|
||||
|
||||
399
src/pages/Slave/SGW/Manager/Fish/component/AddOrUpdateFish.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
import { HTTPSTATUS } from '@/constants';
|
||||
import {
|
||||
apiCreateFishSpecies,
|
||||
apiUpdateFishSpecies,
|
||||
} from '@/services/slave/sgw/FishController';
|
||||
import {
|
||||
apiDeletePhoto,
|
||||
apiUploadPhoto,
|
||||
} from '@/services/slave/sgw/PhotoController';
|
||||
import {
|
||||
ModalForm,
|
||||
ProForm,
|
||||
ProFormInstance,
|
||||
ProFormSelect,
|
||||
ProFormText,
|
||||
ProFormTextArea,
|
||||
ProFormUploadButton,
|
||||
} from '@ant-design/pro-components';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { MessageInstance } from 'antd/es/message/interface';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
export type AddOrUpdateFishProps = {
|
||||
type: 'create' | 'update';
|
||||
fish?: SgwModel.Fish;
|
||||
isOpen?: boolean;
|
||||
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
message: MessageInstance;
|
||||
onReload?: (isSuccess: boolean) => void;
|
||||
};
|
||||
const AddOrUpdateFish = ({
|
||||
type,
|
||||
fish,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
message,
|
||||
onReload,
|
||||
}: AddOrUpdateFishProps) => {
|
||||
const formRef = useRef<ProFormInstance<SgwModel.Fish>>();
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [originalFileList, setOriginalFileList] = useState<UploadFile[]>([]);
|
||||
const intl = useIntl();
|
||||
// Check ảnh có thay đổi so với ban đầu không
|
||||
const hasImageChanged = () => {
|
||||
const currentHasImage = fileList.length > 0;
|
||||
const originalHasImage = originalFileList.length > 0;
|
||||
|
||||
// Nếu số lượng ảnh khác nhau → có thay đổi
|
||||
if (currentHasImage !== originalHasImage) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Nếu cả 2 đều rỗng → không thay đổi
|
||||
if (!currentHasImage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Nếu có ảnh, check xem file có phải là file mới upload không
|
||||
// (file gốc có uid = '-1', file mới upload có uid khác)
|
||||
const currentFile = fileList[0];
|
||||
const isOriginalImage =
|
||||
currentFile.uid === '-1' && currentFile.status === 'done';
|
||||
|
||||
return !isOriginalImage;
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalForm<SgwModel.Fish>
|
||||
key={fish?.id || 'new'}
|
||||
open={isOpen}
|
||||
formRef={formRef}
|
||||
title={
|
||||
type === 'create'
|
||||
? intl.formatMessage({
|
||||
id: 'fish.create.title',
|
||||
defaultMessage: 'Thêm cá mới',
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: 'fish.update.title',
|
||||
defaultMessage: 'Cập nhật cá',
|
||||
})
|
||||
}
|
||||
onOpenChange={setIsOpen}
|
||||
layout="vertical"
|
||||
modalProps={{
|
||||
destroyOnHidden: true,
|
||||
}}
|
||||
request={async () => {
|
||||
if (type === 'update' && fish) {
|
||||
return fish;
|
||||
}
|
||||
setFileList([]);
|
||||
setOriginalFileList([]);
|
||||
return {};
|
||||
}}
|
||||
onFinish={async (values) => {
|
||||
// 1. Cập nhật thông tin cá
|
||||
if (type === 'create') {
|
||||
// TODO: Gọi API tạo cá mới
|
||||
// const result = await apiCreateFish(values);
|
||||
console.log('Create fish:', values);
|
||||
try {
|
||||
const resp = await apiCreateFishSpecies(values);
|
||||
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||
message.success(
|
||||
intl.formatMessage({
|
||||
id: 'fish.create.success',
|
||||
defaultMessage: 'Tạo cá thành công',
|
||||
}),
|
||||
);
|
||||
onReload?.(true);
|
||||
const id = resp.data.name_ids![0];
|
||||
if (fileList.length > 0 && fileList[0].originFileObj && id) {
|
||||
// TODO: Sau khi có result.id từ API create
|
||||
// await apiUploadPhoto('fish', result.id, fileList[0].originFileObj);
|
||||
console.log('Upload photo for new fish');
|
||||
try {
|
||||
const resp = await apiUploadPhoto(
|
||||
'fish',
|
||||
id.toString(),
|
||||
fileList[0].originFileObj,
|
||||
);
|
||||
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||
message.success(
|
||||
intl.formatMessage({
|
||||
id: 'fish.create.image.success',
|
||||
defaultMessage: 'Thêm ảnh cá thành công',
|
||||
}),
|
||||
);
|
||||
onReload?.(true);
|
||||
} else {
|
||||
throw new Error('Thêm ảnh thất bại');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(
|
||||
intl.formatMessage({
|
||||
id: 'fish.create.image.fail',
|
||||
defaultMessage: 'Thêm ảnh cá thất bại',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error('Tạo cá thất bại');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(
|
||||
intl.formatMessage({
|
||||
id: 'fish.create.fail',
|
||||
defaultMessage: 'Tạo cá thất bại',
|
||||
}),
|
||||
);
|
||||
}
|
||||
// 2. Upload ảnh (nếu có chọn ảnh)
|
||||
onReload?.(true);
|
||||
} else {
|
||||
// TODO: Gọi API cập nhật cá
|
||||
// await apiUpdateFish(fish!.id!, values);
|
||||
console.log('Update fish:', fish?.id, values);
|
||||
|
||||
// Check nếu dữ liệu có thay đổi so với ban đầu
|
||||
const hasDataChanged =
|
||||
fish!.name !== values.name ||
|
||||
fish!.scientific_name !== values.scientific_name ||
|
||||
fish!.group_name !== values.group_name ||
|
||||
fish!.rarity_level !== values.rarity_level ||
|
||||
fish!.note !== values.note;
|
||||
|
||||
if (hasDataChanged) {
|
||||
try {
|
||||
const body = { ...values, id: fish!.id! };
|
||||
const resp = await apiUpdateFishSpecies(body);
|
||||
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||
message.success(
|
||||
intl.formatMessage({
|
||||
id: 'fish.update.success',
|
||||
defaultMessage: 'Cập nhật cá thành công',
|
||||
}),
|
||||
);
|
||||
onReload?.(true);
|
||||
} else {
|
||||
throw new Error('Cập nhật cá thất bại');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(
|
||||
intl.formatMessage({
|
||||
id: 'fish.update.fail',
|
||||
defaultMessage: 'Cập nhật cá thất bại',
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
console.log('Dữ liệu không thay đổi, bỏ qua API update');
|
||||
}
|
||||
// 2. Upload ảnh (chỉ khi ảnh có thay đổi)
|
||||
if (hasImageChanged()) {
|
||||
if (fileList.length > 0 && fileList[0].originFileObj) {
|
||||
// TODO: Upload ảnh mới
|
||||
// await apiUploadPhoto('fish', fish!.id!, fileList[0].originFileObj);
|
||||
try {
|
||||
const resp = await apiUploadPhoto(
|
||||
'fish',
|
||||
fish!.id!.toString(),
|
||||
fileList[0].originFileObj,
|
||||
);
|
||||
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||
message.success(
|
||||
intl.formatMessage({
|
||||
id: 'fish.update.image.success',
|
||||
defaultMessage: 'Cập nhật ảnh cá thành công',
|
||||
}),
|
||||
);
|
||||
onReload?.(true);
|
||||
} else {
|
||||
throw new Error('Cập nhật ảnh thất bại');
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(
|
||||
intl.formatMessage({
|
||||
id: 'fish.update.image.fail',
|
||||
defaultMessage: 'Cập nhật ảnh cá thất bại',
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// TODO: Xóa ảnh (nếu có API delete)
|
||||
console.log('Remove photo');
|
||||
const resp = await apiDeletePhoto('fish', fish!.id!);
|
||||
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||
message.success(
|
||||
intl.formatMessage({
|
||||
id: 'fish.delete.image.success',
|
||||
defaultMessage: 'Xóa ảnh cá thành công',
|
||||
}),
|
||||
);
|
||||
onReload?.(true);
|
||||
} else {
|
||||
message.error(
|
||||
intl.formatMessage({
|
||||
id: 'fish.delete.image.fail',
|
||||
defaultMessage: 'Xóa ảnh cá thất bại',
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
{type === 'create' && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<ProFormUploadButton
|
||||
name="upload"
|
||||
label={null}
|
||||
title="Chọn ảnh"
|
||||
accept="image/*"
|
||||
max={1}
|
||||
transform={(value) => ({ upload: value })}
|
||||
fieldProps={{
|
||||
onChange(info) {
|
||||
setFileList(info.fileList);
|
||||
},
|
||||
listType: 'picture-card',
|
||||
fileList: fileList,
|
||||
onRemove: () => {
|
||||
setFileList([]);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ProForm.Group>
|
||||
<ProFormText
|
||||
name="name"
|
||||
width="md"
|
||||
label={intl.formatMessage({
|
||||
id: 'common.name',
|
||||
defaultMessage: 'Tên',
|
||||
})}
|
||||
tooltip={intl.formatMessage({
|
||||
id: 'fish.name.tooltip',
|
||||
defaultMessage: 'Tên loài cá',
|
||||
})}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'fish.name.placeholder',
|
||||
defaultMessage: 'Nhập tên cá',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({
|
||||
id: 'fish.name.required',
|
||||
defaultMessage: 'Tên cá không được để trống',
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<ProFormText
|
||||
name="scientific_name"
|
||||
width="md"
|
||||
label={intl.formatMessage({
|
||||
id: 'fish.specific_name',
|
||||
defaultMessage: 'Tên khoa học',
|
||||
})}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'fish.specific_name.placeholder',
|
||||
defaultMessage: 'Nhập tên khoa học',
|
||||
})}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProForm.Group>
|
||||
<ProFormText
|
||||
name="group_name"
|
||||
label={intl.formatMessage({
|
||||
id: 'fish.fish_group',
|
||||
defaultMessage: 'Nhóm',
|
||||
})}
|
||||
width="md"
|
||||
tooltip={intl.formatMessage({
|
||||
id: 'fish.fish_group.tooltip',
|
||||
defaultMessage: 'Nhóm cá',
|
||||
})}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'fish.fish_group.placeholder',
|
||||
defaultMessage: 'Nhập nhóm cá',
|
||||
})}
|
||||
/>
|
||||
|
||||
<ProFormSelect
|
||||
name="rarity_level"
|
||||
label={intl.formatMessage({
|
||||
id: 'fish.rarity',
|
||||
defaultMessage: 'Độ hiếm',
|
||||
})}
|
||||
width="md"
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'fish.rarity.placeholder',
|
||||
defaultMessage: 'Chọn độ hiếm',
|
||||
})}
|
||||
options={[
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'fish.rarity.normal',
|
||||
defaultMessage: 'Phổ biến',
|
||||
}),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'fish.rarity.sensitive',
|
||||
defaultMessage: 'Dễ bị tổn thương',
|
||||
}),
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'fish.rarity.near_threatened',
|
||||
defaultMessage: 'Gần bị đe dọa',
|
||||
}),
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'fish.rarity.endangered',
|
||||
defaultMessage: 'Nguy cấp',
|
||||
}),
|
||||
value: 4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ProForm.Group>
|
||||
<ProFormTextArea
|
||||
name="note"
|
||||
label={intl.formatMessage({
|
||||
id: 'common.description',
|
||||
defaultMessage: 'Ghi chú',
|
||||
})}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'common.description.placeholder',
|
||||
defaultMessage: 'Nhập ghi chú',
|
||||
})}
|
||||
/>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddOrUpdateFish;
|
||||
66
src/pages/Slave/SGW/Manager/Fish/component/FishImage.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { HTTPSTATUS } from '@/constants';
|
||||
import { apiGetPhoto } from '@/services/slave/sgw/PhotoController';
|
||||
import { Image, Spin } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export const FishImage = ({
|
||||
fishId,
|
||||
alt,
|
||||
isReload,
|
||||
}: {
|
||||
fishId: string;
|
||||
alt: string;
|
||||
isReload: boolean;
|
||||
}) => {
|
||||
const [url, setUrl] = useState<string>('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const objectUrlRef = useRef<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const fetchImage = async () => {
|
||||
try {
|
||||
const resp = await apiGetPhoto('fish', fishId);
|
||||
let objectUrl = '';
|
||||
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||
const blob = new Blob([resp.data], { type: 'image/jpeg' });
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
objectUrlRef.current = objectUrl;
|
||||
} else {
|
||||
throw new Error('Failed to fetch image');
|
||||
}
|
||||
|
||||
if (isMounted) {
|
||||
setUrl(objectUrl);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
// console.log('Error: ', error);
|
||||
setUrl('');
|
||||
if (isMounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchImage();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
}
|
||||
};
|
||||
}, [fishId, isReload]);
|
||||
|
||||
if (loading) {
|
||||
return <Spin size="small" />;
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
|
||||
return <Image height={50} width={50} src={url} alt={alt} />;
|
||||
};
|
||||
@@ -1,11 +1,350 @@
|
||||
import React from 'react';
|
||||
import PhotoActionModal from '@/components/shared/PhotoActionModal';
|
||||
import { DEFAULT_PAGE_SIZE, HTTPSTATUS } from '@/constants';
|
||||
import {
|
||||
apiDeleteFishSpecies,
|
||||
apiGetFishSpecies,
|
||||
} from '@/services/slave/sgw/FishController';
|
||||
import { getRarityById } from '@/utils/slave/sgw/fishRarity';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PictureOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||
import { Button, Flex, message, Popconfirm, Tag, theme, Tooltip } from 'antd';
|
||||
import { useRef, useState } from 'react';
|
||||
import AddOrUpdateFish from './component/AddOrUpdateFish';
|
||||
import { FishImage } from './component/FishImage';
|
||||
|
||||
const FishList = () => {
|
||||
const tableRef = useRef<ActionType>();
|
||||
const intl = useIntl();
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [showAddOrUpdateModal, setShowAddOrUpdateModal] =
|
||||
useState<boolean>(false);
|
||||
const [fishSelected, setFishSelected] = useState<SgwModel.Fish | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [fishPhotoModalOpen, setFishPhotoModalOpen] = useState<boolean>(false);
|
||||
const [fishID, setFishID] = useState<number | undefined>(undefined);
|
||||
const [isReloadImage, setIsReloadImage] = useState<boolean>(false);
|
||||
const token = theme.useToken();
|
||||
const getColorByRarityLevel = (level: number) => {
|
||||
switch (level) {
|
||||
case 2:
|
||||
return token.token.yellow;
|
||||
case 3:
|
||||
return token.token.orange;
|
||||
case 4:
|
||||
return token.token.colorError;
|
||||
case 5:
|
||||
return '#FF6347';
|
||||
case 6:
|
||||
return '#FF4500';
|
||||
case 7:
|
||||
return '#FF0000';
|
||||
case 8:
|
||||
return '#8B0000';
|
||||
default:
|
||||
return token.token.green;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFish = async (id: string) => {
|
||||
try {
|
||||
const resp = await apiDeleteFishSpecies(id);
|
||||
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||
messageApi.success(
|
||||
intl.formatMessage({
|
||||
id: 'fish.delete.success',
|
||||
defaultMessage: 'Successfully deleted fish',
|
||||
}),
|
||||
);
|
||||
tableRef.current?.reload();
|
||||
} else {
|
||||
throw new Error('Xóa cá thất bại');
|
||||
}
|
||||
} catch (error) {
|
||||
messageApi.error(
|
||||
intl.formatMessage({
|
||||
id: 'fish.delete.fail',
|
||||
defaultMessage: 'Failed to delete fish',
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ProColumns<SgwModel.Fish>[] = [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'common.name',
|
||||
defaultMessage: 'Name',
|
||||
}),
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
copyable: true,
|
||||
render: (dom, entity) => {
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
intl.formatMessage({
|
||||
id: 'fish.specific_name',
|
||||
defaultMessage: 'Scientific Name',
|
||||
}) +
|
||||
': ' +
|
||||
entity.scientific_name
|
||||
}
|
||||
>
|
||||
{dom}
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'common.image',
|
||||
defaultMessage: 'Image',
|
||||
}),
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
hideInSearch: true,
|
||||
// valueType: 'image',
|
||||
render: (_, entity) => {
|
||||
return (
|
||||
<FishImage
|
||||
fishId={String(entity.id || '')}
|
||||
alt={entity.name || ''}
|
||||
isReload={isReloadImage}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'fish.fish_group',
|
||||
defaultMessage: 'Group',
|
||||
}),
|
||||
dataIndex: 'group_name',
|
||||
key: 'group_name',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'fish.rarity',
|
||||
defaultMessage: 'Rarity',
|
||||
}),
|
||||
dataIndex: 'rarity_level',
|
||||
key: 'rarity_level',
|
||||
valueType: 'select',
|
||||
valueEnum: {
|
||||
1: {
|
||||
text: intl.formatMessage({
|
||||
id: 'fish.rarity.normal',
|
||||
defaultMessage: 'Common',
|
||||
}),
|
||||
},
|
||||
2: {
|
||||
text: intl.formatMessage({
|
||||
id: 'fish.rarity.sensitive',
|
||||
defaultMessage: 'Sensitive',
|
||||
}),
|
||||
},
|
||||
3: {
|
||||
text: intl.formatMessage({
|
||||
id: 'fish.rarity.near_threatened',
|
||||
defaultMessage: 'Near Threatened',
|
||||
}),
|
||||
},
|
||||
4: {
|
||||
text: intl.formatMessage({
|
||||
id: 'fish.rarity.endangered',
|
||||
defaultMessage: 'Endangered',
|
||||
}),
|
||||
},
|
||||
},
|
||||
render: (_, entity) => {
|
||||
const rarity = getRarityById(entity.rarity_level || 1);
|
||||
return (
|
||||
<Tag color={getColorByRarityLevel(entity.rarity_level || 1)}>
|
||||
{rarity ||
|
||||
intl.formatMessage({
|
||||
id: 'common.undefined',
|
||||
defaultMessage: 'Undefined',
|
||||
})}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'common.note',
|
||||
defaultMessage: 'Note',
|
||||
}),
|
||||
dataIndex: 'note',
|
||||
hideInSearch: true,
|
||||
key: 'note',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'common.actions',
|
||||
defaultMessage: 'Actions',
|
||||
}),
|
||||
dataIndex: 'actions',
|
||||
key: 'actions',
|
||||
hideInSearch: true,
|
||||
align: 'center',
|
||||
render: (_, entity) => {
|
||||
return (
|
||||
<Flex align="center" justify="center" gap={8}>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => {
|
||||
setFishSelected(entity);
|
||||
setShowAddOrUpdateModal(true);
|
||||
}}
|
||||
icon={<EditOutlined />}
|
||||
></Button>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={async () => {
|
||||
setFishID(entity.id);
|
||||
setFishPhotoModalOpen(true);
|
||||
}}
|
||||
icon={<PictureOutlined />}
|
||||
></Button>
|
||||
<Popconfirm
|
||||
title={intl.formatMessage({
|
||||
id: 'fish.delete_confirm',
|
||||
defaultMessage:
|
||||
'Are you sure you want to delete this fish species?',
|
||||
})}
|
||||
onConfirm={() => handleDeleteFish(entity.id?.toString() || '')}
|
||||
okText={intl.formatMessage({
|
||||
id: 'common.yes',
|
||||
defaultMessage: 'Yes',
|
||||
})}
|
||||
cancelText={intl.formatMessage({
|
||||
id: 'common.no',
|
||||
defaultMessage: 'No',
|
||||
})}
|
||||
>
|
||||
<Button type="text" danger icon={<DeleteOutlined />}></Button>
|
||||
</Popconfirm>
|
||||
</Flex>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const SGWFish: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Cá (SGW Manager)</h1>
|
||||
{contextHolder}
|
||||
<AddOrUpdateFish
|
||||
type={fishSelected ? 'update' : 'create'}
|
||||
isOpen={showAddOrUpdateModal}
|
||||
setIsOpen={setShowAddOrUpdateModal}
|
||||
fish={fishSelected}
|
||||
message={messageApi}
|
||||
onReload={(isSuccess) => {
|
||||
if (isSuccess) {
|
||||
tableRef.current?.reload();
|
||||
setIsReloadImage((prev) => !prev);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<PhotoActionModal
|
||||
key={fishID ?? 'none'}
|
||||
isOpen={fishPhotoModalOpen}
|
||||
setIsOpen={setFishPhotoModalOpen}
|
||||
type={'fish'}
|
||||
id={fishID!}
|
||||
hasSubPhotos={true}
|
||||
/>
|
||||
<ProTable<SgwModel.Fish>
|
||||
actionRef={tableRef}
|
||||
rowKey="id"
|
||||
size="large"
|
||||
columns={columns}
|
||||
search={{
|
||||
defaultCollapsed: false,
|
||||
}}
|
||||
columnEmptyText="-"
|
||||
pagination={{
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['5', '10', '15', '20'],
|
||||
showTotal: (total, range) =>
|
||||
`${range[0]}-${range[1]}
|
||||
${intl.formatMessage({
|
||||
id: 'common.of',
|
||||
defaultMessage: 'of',
|
||||
})}
|
||||
${total} ${intl.formatMessage({
|
||||
id: 'fish.name',
|
||||
defaultMessage: 'fishes',
|
||||
})}`,
|
||||
}}
|
||||
options={{
|
||||
search: false,
|
||||
setting: false,
|
||||
density: false,
|
||||
reload: true,
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Button
|
||||
color="cyan"
|
||||
variant="outlined"
|
||||
key="add-fish"
|
||||
icon={<PlusOutlined />}
|
||||
size="middle"
|
||||
onClick={() => {
|
||||
setFishSelected(undefined);
|
||||
setShowAddOrUpdateModal(true);
|
||||
}}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="fish.create.title"
|
||||
defaultMessage="Add Fish Species"
|
||||
/>
|
||||
</Button>,
|
||||
]}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, name, group_name, rarity_level } = params;
|
||||
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
|
||||
const body: SgwModel.SearchFishPaginationBody = {
|
||||
name: name,
|
||||
order: 'name',
|
||||
limit: pageSize,
|
||||
offset: offset,
|
||||
dir: 'desc',
|
||||
};
|
||||
if (group_name || rarity_level) body.metadata = {};
|
||||
if (group_name && body.metadata) {
|
||||
body.metadata.group_name = group_name;
|
||||
}
|
||||
if (rarity_level && body.metadata) {
|
||||
body.metadata.rarity_level = Number(rarity_level);
|
||||
}
|
||||
try {
|
||||
const res = await apiGetFishSpecies(body);
|
||||
return {
|
||||
data: res.fishes,
|
||||
total: res.total,
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
data: [],
|
||||
total: 0,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SGWFish;
|
||||
export default FishList;
|
||||
|
||||
1200
src/pages/Slave/SGW/Map/components/BaseMap.ts
Normal file
74
src/pages/Slave/SGW/Map/components/CircleStyle.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { FeatureLike } from 'ol/Feature';
|
||||
import { Coordinate } from 'ol/coordinate';
|
||||
import { Circle, Point } from 'ol/geom';
|
||||
import { Fill, Stroke, Style, Text } from 'ol/style';
|
||||
import { ZoneData } from '../type';
|
||||
|
||||
const getCircleStyleFromData = ({ zoneData }: { zoneData: ZoneData }) => {
|
||||
// Base style configuration for each zone type
|
||||
console.log('Type: ', zoneData.type);
|
||||
|
||||
const circleStyles = {
|
||||
warning: {
|
||||
fill: new Fill({ color: 'rgba(250, 206, 104, 0.5)' }), // Yellow with transparency
|
||||
stroke: new Stroke({ color: '#FFC107', width: 2 }),
|
||||
textColor: '#856404',
|
||||
},
|
||||
alarm: {
|
||||
fill: new Fill({ color: 'rgba(220, 53, 69, 0.8)' }), // Red with transparency
|
||||
stroke: new Stroke({ color: '#DC3545', width: 3 }),
|
||||
textColor: '#721C24',
|
||||
},
|
||||
default: {
|
||||
fill: new Fill({ color: 'rgba(108, 117, 125, 0.2)' }), // Gray with transparency
|
||||
stroke: new Stroke({ color: '#6C757D', width: 1 }),
|
||||
textColor: '#383D41',
|
||||
},
|
||||
};
|
||||
|
||||
const styleConfig = circleStyles[zoneData.type] || circleStyles.default;
|
||||
|
||||
// Create styles array - we might need multiple styles for proper rendering
|
||||
const styles: Style[] = [];
|
||||
|
||||
// Main style for Circle geometry (uses fill/stroke directly, not image)
|
||||
const mainStyle = new Style({
|
||||
fill: styleConfig.fill,
|
||||
stroke: styleConfig.stroke,
|
||||
});
|
||||
styles.push(mainStyle);
|
||||
|
||||
// Add text style if message exists
|
||||
if (zoneData.message && zoneData.message.trim()) {
|
||||
const textStyle = new Style({
|
||||
geometry: (feature: FeatureLike) => {
|
||||
// Get the center of the circle for text placement
|
||||
const geometry = feature.getGeometry();
|
||||
if (geometry && geometry.getType() === 'Circle') {
|
||||
// For Circle geometry, get the center and return it as a Point geometry
|
||||
const circle = geometry as Circle;
|
||||
const center: Coordinate = circle.getCenter();
|
||||
return new Point(center);
|
||||
}
|
||||
return geometry;
|
||||
},
|
||||
text: new Text({
|
||||
font: 'bold 14px Arial, sans-serif',
|
||||
text: zoneData.message,
|
||||
fill: new Fill({ color: styleConfig.textColor }),
|
||||
stroke: new Stroke({
|
||||
color: 'white',
|
||||
width: 3,
|
||||
}),
|
||||
textAlign: 'center',
|
||||
textBaseline: 'middle',
|
||||
}),
|
||||
});
|
||||
styles.push(textStyle);
|
||||
}
|
||||
|
||||
// Return single style if no text, or array if text exists
|
||||
return styles.length === 1 ? styles[0] : styles;
|
||||
};
|
||||
|
||||
export default getCircleStyleFromData;
|
||||
64
src/pages/Slave/SGW/Map/components/MultipleShips.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Collapse, Flex, Typography } from 'antd';
|
||||
import { MessageInstance } from 'antd/es/message/interface';
|
||||
import { GPSParseResult } from '../type';
|
||||
import { BaseMap } from './BaseMap';
|
||||
import ShipDetail from './ShipDetail';
|
||||
|
||||
const MultipleShips = ({
|
||||
things,
|
||||
messageApi,
|
||||
mapController,
|
||||
}: {
|
||||
things: SgwModel.SgwThing[];
|
||||
messageApi: MessageInstance;
|
||||
mapController: BaseMap | null;
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Collapse
|
||||
bordered={false}
|
||||
style={{
|
||||
background: 'white',
|
||||
}}
|
||||
// onChange={handleCollapseChange}
|
||||
items={things.map((thing, index) => {
|
||||
const gpsData: GPSParseResult = JSON.parse(thing.metadata?.gps || '{}');
|
||||
return {
|
||||
key: index,
|
||||
label: (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{ width: '100%', marginBottom: 10 }}
|
||||
>
|
||||
<Typography.Text strong>
|
||||
{thing.metadata?.ship_name || thing.name}
|
||||
</Typography.Text>
|
||||
<div style={{ display: 'flex', gap: '10px' }}>
|
||||
<Typography.Text strong>
|
||||
${intl.formatMessage({ id: 'map.ship_detail.speed' })}:{' '}
|
||||
{gpsData.s} km/h
|
||||
</Typography.Text>
|
||||
<Typography.Text>-</Typography.Text>
|
||||
<Typography.Text strong>
|
||||
{intl.formatMessage({ id: 'map.ship_detail.heading' })}:{' '}
|
||||
{gpsData.h}°
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Flex>
|
||||
),
|
||||
children: (
|
||||
<ShipDetail
|
||||
thing={thing}
|
||||
messageApi={messageApi}
|
||||
mapController={mapController}
|
||||
/>
|
||||
),
|
||||
};
|
||||
})}
|
||||
></Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultipleShips;
|
||||
56
src/pages/Slave/SGW/Map/components/PolygonStyle.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Fill, Stroke, Style, Text } from 'ol/style';
|
||||
import { ZoneData } from '../type';
|
||||
|
||||
const getZoneStyleFromData = ({ zoneData }: { zoneData: ZoneData }) => {
|
||||
// Base style configuration for each zone type
|
||||
const zoneStyles = {
|
||||
warning: {
|
||||
fill: new Fill({ color: 'rgba(255, 193, 7, 0.3)' }), // Yellow with transparency
|
||||
stroke: new Stroke({ color: '#FFC107', width: 2 }),
|
||||
textColor: '#856404',
|
||||
},
|
||||
alarm: {
|
||||
fill: new Fill({ color: 'rgba(220, 53, 69, 0.3)' }), // Red with transparency
|
||||
stroke: new Stroke({ color: '#DC3545', width: 3 }),
|
||||
textColor: '#721C24',
|
||||
},
|
||||
default: {
|
||||
fill: new Fill({ color: 'rgba(108, 117, 125, 0.2)' }), // Gray with transparency
|
||||
stroke: new Stroke({ color: '#6C757D', width: 1 }),
|
||||
textColor: '#383D41',
|
||||
},
|
||||
};
|
||||
|
||||
const styleConfig = zoneStyles[zoneData.type] || zoneStyles.default;
|
||||
|
||||
// Create the base style
|
||||
const baseStyle = {
|
||||
fill: styleConfig.fill,
|
||||
stroke: styleConfig.stroke,
|
||||
};
|
||||
|
||||
// Add text style if message exists
|
||||
if (zoneData.message && zoneData.message.trim()) {
|
||||
return new Style({
|
||||
...baseStyle,
|
||||
text: new Text({
|
||||
font: 'bold 14px Arial, sans-serif',
|
||||
text: zoneData.message,
|
||||
fill: new Fill({ color: styleConfig.textColor }),
|
||||
stroke: new Stroke({
|
||||
color: 'white',
|
||||
width: 3,
|
||||
}),
|
||||
placement: 'point', // Center text in polygon
|
||||
overflow: true, // Allow text to overflow if needed
|
||||
textAlign: 'center',
|
||||
textBaseline: 'middle',
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Return style without text if no message
|
||||
return new Style(baseStyle);
|
||||
};
|
||||
|
||||
export default getZoneStyleFromData;
|
||||
65
src/pages/Slave/SGW/Map/components/PolylineStyle.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Fill, Stroke, Style, Text } from 'ol/style';
|
||||
import { ZoneData } from '../type';
|
||||
|
||||
const getPolylineStyleFromData = ({ zoneData }: { zoneData: ZoneData }) => {
|
||||
// Base style configuration for each zone type
|
||||
const polylineStyles = {
|
||||
warning: {
|
||||
stroke: new Stroke({
|
||||
color: '#FFC107', // Yellow
|
||||
width: 4,
|
||||
lineDash: [10, 5], // Dashed line for warning
|
||||
}),
|
||||
textColor: '#856404',
|
||||
},
|
||||
alarm: {
|
||||
stroke: new Stroke({
|
||||
color: '#DC3545', // Red
|
||||
width: 5,
|
||||
lineDash: [15, 5], // More prominent dash for alarm
|
||||
}),
|
||||
textColor: '#721C24',
|
||||
},
|
||||
default: {
|
||||
stroke: new Stroke({
|
||||
color: '#6C757D', // Gray
|
||||
width: 3,
|
||||
lineDash: [], // Solid line for default
|
||||
}),
|
||||
textColor: '#383D41',
|
||||
},
|
||||
};
|
||||
|
||||
const styleConfig = polylineStyles[zoneData.type] || polylineStyles.default;
|
||||
|
||||
// Create the base style
|
||||
const baseStyle = {
|
||||
stroke: styleConfig.stroke,
|
||||
};
|
||||
|
||||
// Add text style if message exists
|
||||
if (zoneData.message && zoneData.message.trim()) {
|
||||
return new Style({
|
||||
...baseStyle,
|
||||
text: new Text({
|
||||
font: 'bold 12px Arial, sans-serif',
|
||||
text: zoneData.message,
|
||||
fill: new Fill({ color: styleConfig.textColor }),
|
||||
stroke: new Stroke({
|
||||
color: 'white',
|
||||
width: 3,
|
||||
}),
|
||||
placement: 'line', // Place text along the line
|
||||
overflow: true,
|
||||
textAlign: 'center',
|
||||
textBaseline: 'middle',
|
||||
repeat: 100000000, // Repeat text every 200 pixels
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Return style without text if no message
|
||||
return new Style(baseStyle);
|
||||
};
|
||||
|
||||
export default getPolylineStyleFromData;
|
||||
119
src/pages/Slave/SGW/Map/components/ShipBasicInfo.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { ProDescriptions } from '@ant-design/pro-components';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Flex, Image } from 'antd';
|
||||
import { ShipDetailData } from '../type';
|
||||
|
||||
interface ShipBasicInfoProps {
|
||||
ship: ShipDetailData | null;
|
||||
shipImage: string;
|
||||
thing: SgwModel.SgwThing;
|
||||
}
|
||||
|
||||
const ShipBasicInfo = ({ ship, shipImage, thing }: ShipBasicInfoProps) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Flex
|
||||
gap="middle"
|
||||
style={{
|
||||
flexDirection: window.innerWidth <= 768 ? 'column' : 'row',
|
||||
alignItems: window.innerWidth <= 768 ? 'center' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: window.innerWidth <= 768 ? '100%' : '30%',
|
||||
maxWidth: '300px',
|
||||
margin: window.innerWidth <= 768 ? '0 auto' : '0',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={shipImage}
|
||||
style={{ borderRadius: '8px', objectFit: 'cover' }}
|
||||
width="100%"
|
||||
height="120px"
|
||||
alt="Ảnh tàu"
|
||||
preview={false}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).src =
|
||||
'https://1.semantic-ui.com/images/wireframe/image.png';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Thông tin cơ bản */}
|
||||
<div
|
||||
style={{
|
||||
width: window.innerWidth <= 768 ? '100%' : '70%',
|
||||
}}
|
||||
>
|
||||
<ProDescriptions
|
||||
size="small"
|
||||
column={2}
|
||||
styles={{
|
||||
title: {
|
||||
marginBottom: -10,
|
||||
textAlign: 'center',
|
||||
fontSize: window.innerWidth <= 576 ? '14px' : '16px',
|
||||
},
|
||||
content: { paddingBottom: 1 },
|
||||
label: { paddingBottom: 1 },
|
||||
}}
|
||||
>
|
||||
<ProDescriptions.Item
|
||||
label={intl.formatMessage({
|
||||
id: 'map.filter.ship_name',
|
||||
defaultMessage: 'Ship Name',
|
||||
})}
|
||||
>
|
||||
{ship?.ship?.name || '-'}
|
||||
</ProDescriptions.Item>
|
||||
<ProDescriptions.Item
|
||||
label={intl.formatMessage({
|
||||
id: 'map.ship_detail.port_register',
|
||||
defaultMessage: 'Port Register',
|
||||
})}
|
||||
>
|
||||
{ship?.ship?.metadata?.home_port || '-'}
|
||||
</ProDescriptions.Item>
|
||||
<ProDescriptions.Item
|
||||
label={intl.formatMessage({
|
||||
id: 'map.ship_detail.speed',
|
||||
defaultMessage: 'Speed',
|
||||
})}
|
||||
>
|
||||
{ship?.gps?.s || '-'} km/h
|
||||
</ProDescriptions.Item>
|
||||
<ProDescriptions.Item
|
||||
label={intl.formatMessage({
|
||||
id: 'map.ship_detail.heading',
|
||||
defaultMessage: 'Heading',
|
||||
})}
|
||||
>
|
||||
{ship?.gps?.h || '-'}°
|
||||
</ProDescriptions.Item>
|
||||
<ProDescriptions.Item
|
||||
label={intl.formatMessage({
|
||||
id: 'map.ship_detail.status',
|
||||
defaultMessage: 'Status',
|
||||
})}
|
||||
span={3}
|
||||
>
|
||||
{ship?.gps?.fishing ? 'Đang đánh bắt' : 'Chưa đánh bắt'}
|
||||
</ProDescriptions.Item>
|
||||
<ProDescriptions.Item
|
||||
valueType="fromNow"
|
||||
label={intl.formatMessage({
|
||||
id: 'map.ship_detail.updated',
|
||||
defaultMessage: 'Updated',
|
||||
})}
|
||||
>
|
||||
{thing.metadata?.updated_time
|
||||
? thing.metadata?.updated_time * 1000
|
||||
: '-'}
|
||||
</ProDescriptions.Item>
|
||||
</ProDescriptions>
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShipBasicInfo;
|
||||
282
src/pages/Slave/SGW/Map/components/ShipDetail.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
import { apiQueryUserById } from '@/services/master/UserController';
|
||||
import { apiGetPhoto } from '@/services/slave/sgw/PhotoController';
|
||||
import { apiViewShip } from '@/services/slave/sgw/ShipController';
|
||||
import { apiGetZoneById } from '@/services/slave/sgw/ZoneController';
|
||||
import {
|
||||
convertWKTLineStringToLatLngArray,
|
||||
convertWKTPointToLatLng,
|
||||
convertWKTtoLatLngString,
|
||||
getAlarmTypeName,
|
||||
getCircleRadius,
|
||||
} from '@/utils/slave/sgw/geomUtils';
|
||||
import { ArrowRightOutlined, InfoCircleOutlined } from '@ant-design/icons';
|
||||
import { ProSkeleton } from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
|
||||
import { Button, Space, Tabs, Tooltip } from 'antd';
|
||||
import { MessageInstance } from 'antd/lib/message/interface';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
BaseMap,
|
||||
DATA_LAYER,
|
||||
GPSParseResult,
|
||||
ShipDetailData,
|
||||
TEMPORARY_LAYER,
|
||||
ZoneAlarmParse,
|
||||
} from '../type';
|
||||
import ShipBasicInfo from './ShipBasicInfo';
|
||||
import { ShipSpecificationTab, ShipTripInfoTab } from './ShipTabs';
|
||||
import ShipWarningList from './ShipWarningList';
|
||||
|
||||
const ShipDetail = ({
|
||||
thing,
|
||||
messageApi,
|
||||
mapController,
|
||||
}: {
|
||||
thing: SgwModel.SgwThing;
|
||||
messageApi: MessageInstance;
|
||||
mapController: BaseMap | null;
|
||||
}) => {
|
||||
const [ship, setShip] = useState<ShipDetailData | null>(null);
|
||||
const intl = useIntl();
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [shipImage, setShipImage] = useState<string>('');
|
||||
const [selectedZoneId, setSelectedZoneId] = useState<string | null>(null);
|
||||
const getShipDetail = async () => {
|
||||
try {
|
||||
let zone_entered_alarm_list: ZoneAlarmParse[] = [];
|
||||
if (thing.metadata?.zone_entered_alarm_list !== '') {
|
||||
zone_entered_alarm_list = JSON.parse(
|
||||
thing.metadata?.zone_entered_alarm_list || '[]',
|
||||
);
|
||||
}
|
||||
let zone_approaching_alarm_list: ZoneAlarmParse[] = [];
|
||||
if (thing.metadata?.zone_approaching_alarm_list !== '') {
|
||||
zone_approaching_alarm_list = JSON.parse(
|
||||
thing.metadata?.zone_approaching_alarm_list || '[]',
|
||||
);
|
||||
}
|
||||
const gpsData: GPSParseResult = JSON.parse(thing.metadata?.gps || '{}');
|
||||
const resp = await apiViewShip(thing.id || '');
|
||||
|
||||
let shipOwner:
|
||||
| MasterModel.UserResponse
|
||||
| MasterModel.UserResponse
|
||||
| null = null;
|
||||
if (resp?.owner_id) {
|
||||
if (initialState?.currentUserProfile?.metadata?.user_type === 'admin') {
|
||||
shipOwner = await apiQueryUserById(resp.owner_id);
|
||||
} else {
|
||||
shipOwner = initialState?.currentUserProfile || null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const photoResponse = await apiGetPhoto('ship', resp.id || '');
|
||||
const blob = new Blob([photoResponse.data], { type: 'image/jpeg' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setShipImage(url);
|
||||
} catch (e) {
|
||||
setShipImage('https://1.semantic-ui.com/images/wireframe/image.png');
|
||||
}
|
||||
setShip({
|
||||
ship: resp,
|
||||
owner: shipOwner || null,
|
||||
zone_entered_alarm_list: zone_entered_alarm_list,
|
||||
zone_approaching_alarm_list: zone_approaching_alarm_list,
|
||||
gps: gpsData,
|
||||
trip_id: resp?.metadata?.trip_id,
|
||||
});
|
||||
setIsLoading(false);
|
||||
} catch (error) {
|
||||
console.error('Error fetching ship details:', error);
|
||||
messageApi.error('Không thể lấy thông tin tàu.');
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
try {
|
||||
getShipDetail();
|
||||
} catch (error) {
|
||||
console.error('Cannot get ShipDetail: ', error);
|
||||
}
|
||||
}, [thing.id]);
|
||||
console.log();
|
||||
const handleClickZoneButton = async (zone: ZoneAlarmParse) => {
|
||||
if (mapController === null) {
|
||||
return;
|
||||
}
|
||||
mapController.toggleLayer(DATA_LAYER, false);
|
||||
if (zone.zone_id === selectedZoneId) {
|
||||
mapController.clearFeatures(TEMPORARY_LAYER);
|
||||
mapController.toggleLayer(DATA_LAYER, true);
|
||||
setSelectedZoneId(null);
|
||||
return;
|
||||
}
|
||||
if (selectedZoneId) {
|
||||
}
|
||||
try {
|
||||
const resp = await apiGetZoneById(zone.zone_id || '');
|
||||
if (resp) {
|
||||
const text = `Tàu: ${ship?.ship?.name || '-'}\nThời gian ${new Date(
|
||||
zone.gps_time! * 1000,
|
||||
).toLocaleTimeString()}\nTốc độ: ${zone.s} km/h\nHướng: ${
|
||||
zone.h
|
||||
}°\n Lý do: ${zone.message}`;
|
||||
mapController.addPoint(
|
||||
TEMPORARY_LAYER,
|
||||
[zone?.lon || 0, zone?.lat || 0],
|
||||
{
|
||||
thing: thing,
|
||||
description: text,
|
||||
type: 'main-point',
|
||||
},
|
||||
);
|
||||
|
||||
const zone_geom: SgwModel.Geom = JSON.parse(resp.geometry || '{}');
|
||||
if (zone_geom.geom_type === 1) {
|
||||
const polygon = convertWKTtoLatLngString(zone_geom.geom_poly || '');
|
||||
mapController.addPolygon(TEMPORARY_LAYER, polygon, {
|
||||
type: getAlarmTypeName(thing.metadata?.state_level || 0),
|
||||
zone: resp,
|
||||
message: zone.zone_name,
|
||||
});
|
||||
} else if (zone_geom.geom_type === 2) {
|
||||
const polyline = convertWKTLineStringToLatLngArray(
|
||||
zone_geom.geom_lines || '',
|
||||
);
|
||||
mapController.addPolyline(TEMPORARY_LAYER, polyline, {
|
||||
type: getAlarmTypeName(thing.metadata?.state_level || 0),
|
||||
zone: resp,
|
||||
message: zone.zone_name,
|
||||
});
|
||||
} else {
|
||||
const center = convertWKTPointToLatLng(zone_geom.geom_point || '');
|
||||
|
||||
mapController.addCircle(
|
||||
TEMPORARY_LAYER,
|
||||
center!,
|
||||
getCircleRadius(zone_geom.geom_radius || 0),
|
||||
{
|
||||
type: getAlarmTypeName(thing.metadata?.state_level || 0),
|
||||
zone: resp,
|
||||
message: zone.zone_name,
|
||||
},
|
||||
);
|
||||
}
|
||||
mapController.zoomToFeaturesInLayer(TEMPORARY_LAYER);
|
||||
setSelectedZoneId(zone.zone_id || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error with get Zone: ', error);
|
||||
}
|
||||
};
|
||||
return isLoading ? (
|
||||
<ProSkeleton type="list" statistic={false} list={1} />
|
||||
) : (
|
||||
<>
|
||||
<ShipBasicInfo ship={ship} shipImage={shipImage} thing={thing} />
|
||||
<Tabs
|
||||
defaultActiveKey="1"
|
||||
centered
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<FormattedMessage
|
||||
id="map.ship_detail.specifications"
|
||||
defaultMessage="Specifications"
|
||||
/>
|
||||
),
|
||||
children: <ShipSpecificationTab ship={ship} />,
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: (
|
||||
<Tooltip
|
||||
title={intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_information_tooltip',
|
||||
defaultMessage: 'Nearest Trip Information',
|
||||
})}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_information',
|
||||
defaultMessage: 'Trip Information',
|
||||
})}
|
||||
</Tooltip>
|
||||
),
|
||||
children: <ShipTripInfoTab ship={ship} />,
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: intl.formatMessage({
|
||||
id: 'map.filter.ship_warning',
|
||||
defaultMessage: 'Warning',
|
||||
}),
|
||||
disabled:
|
||||
ship?.zone_approaching_alarm_list.length === 0 &&
|
||||
ship?.zone_entered_alarm_list.length === 0,
|
||||
children: (
|
||||
<ShipWarningList
|
||||
zoneEnteredList={ship?.zone_entered_alarm_list || []}
|
||||
zoneApproachingList={ship?.zone_approaching_alarm_list || []}
|
||||
selectedZoneId={selectedZoneId}
|
||||
onZoneButtonClick={handleClickZoneButton}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
></Tabs>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShipDetail;
|
||||
|
||||
export const ShipDetailMessageAction = () => {
|
||||
const intl = useIntl();
|
||||
// const goToThingDetail = (trip_id: string) => {
|
||||
// history.push(
|
||||
// { pathname: `/trips/${trip_id}` },
|
||||
// { thingId: trip_id }, // state
|
||||
// );
|
||||
// };
|
||||
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
// type="link".
|
||||
variant="outlined"
|
||||
color="cyan"
|
||||
icon={<ArrowRightOutlined />}
|
||||
size="small"
|
||||
iconPosition="end"
|
||||
onClick={async () => {
|
||||
// const ship = await viewShip(id);
|
||||
// console.log("Ship Data for Trip Navigation:", shipData);
|
||||
// goToThingDetail(shipData?.metadata?.trip_id);
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_information',
|
||||
defaultMessage: 'Trip Information',
|
||||
})}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
iconPosition="end"
|
||||
icon={<InfoCircleOutlined />}
|
||||
onClick={() => {
|
||||
// console.log("Navigating to device detail:", id);
|
||||
// const path = `/devices/${id}/vms`; // lấy id + type từ record
|
||||
// history.push({ pathname: path });
|
||||
}}
|
||||
>
|
||||
{intl.formatMessage({
|
||||
id: 'map.ship_detail.detail',
|
||||
defaultMessage: 'Detail',
|
||||
})}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
69
src/pages/Slave/SGW/Map/components/ShipIconStyle.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { getShipIcon } from '@/services/slave/sgw/MapService';
|
||||
import { Fill, Icon, Stroke, Style, Text } from 'ol/style';
|
||||
import CircleStyle from 'ol/style/Circle';
|
||||
import { GPSParseResult, PointData } from '../type';
|
||||
|
||||
export const getShipStyleFromData = ({
|
||||
state_level,
|
||||
gpsData,
|
||||
scale,
|
||||
pointData,
|
||||
}: {
|
||||
state_level: number;
|
||||
gpsData: GPSParseResult;
|
||||
scale: number;
|
||||
pointData: PointData;
|
||||
}) => {
|
||||
if (pointData.type === 'main-point') {
|
||||
return new Style({
|
||||
text: pointData.description
|
||||
? new Text({
|
||||
text: pointData.description,
|
||||
font: '14px Arial',
|
||||
fill: new Fill({ color: 'black' }),
|
||||
stroke: new Stroke({
|
||||
color: 'white',
|
||||
width: 2,
|
||||
}),
|
||||
offsetY: -40,
|
||||
textAlign: 'center',
|
||||
textBaseline: 'bottom',
|
||||
})
|
||||
: undefined,
|
||||
image: new Icon({
|
||||
anchor: [0.5, 30], // Điểm neo: giữa theo X, 30px theo Y
|
||||
anchorOrigin: 'top-left',
|
||||
anchorXUnits: 'fraction',
|
||||
anchorYUnits: 'pixels',
|
||||
scale: scale,
|
||||
src: getShipIcon(state_level, gpsData.fishing || false),
|
||||
rotateWithView: false,
|
||||
rotation: ((gpsData.h || 0) * Math.PI) / 180,
|
||||
crossOrigin: 'anonymous',
|
||||
}),
|
||||
});
|
||||
} else if (pointData.type === 'sos-point') {
|
||||
// Style cơ bản cho SOS point - hình tròn đỏ ở giữa
|
||||
return new Style({
|
||||
image: new Icon({
|
||||
anchor: [0.5, 30], // Điểm neo: giữa theo X, 30px theo Y
|
||||
anchorOrigin: 'top-left',
|
||||
anchorXUnits: 'fraction',
|
||||
anchorYUnits: 'pixels',
|
||||
scale: scale,
|
||||
src: getShipIcon(state_level, gpsData.fishing || false),
|
||||
rotateWithView: false,
|
||||
rotation: ((gpsData.h || 0) * Math.PI) / 180,
|
||||
crossOrigin: 'anonymous',
|
||||
}),
|
||||
});
|
||||
} else {
|
||||
return new Style({
|
||||
image: new CircleStyle({
|
||||
radius: 5,
|
||||
fill: new Fill({ color: 'red' }),
|
||||
stroke: new Stroke({ color: 'white', width: 2 }),
|
||||
}),
|
||||
});
|
||||
}
|
||||
};
|
||||
407
src/pages/Slave/SGW/Map/components/ShipSearchForm.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
|
||||
import { apiGetShipGroups } from '@/services/slave/sgw/ShipController';
|
||||
import {
|
||||
ModalForm,
|
||||
ProForm,
|
||||
ProFormInstance,
|
||||
ProFormSlider,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
|
||||
import { AutoComplete, Col, Flex, Row, Select, Tooltip } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface SearchShipProps {
|
||||
initialValues?: Partial<SearchShipResponse>;
|
||||
things?: SgwModel.SgwThing[];
|
||||
isModalOpen: boolean;
|
||||
onClose: any;
|
||||
onSubmit: (values: SearchShipResponse) => void;
|
||||
}
|
||||
|
||||
export interface SearchShipResponse {
|
||||
ship_name?: string;
|
||||
ship_length?: [number, number];
|
||||
reg_number?: string;
|
||||
ship_power?: [number, number];
|
||||
ship_type?: string | number;
|
||||
alarm_list?: string;
|
||||
ship_group_id?: string;
|
||||
group_id?: string | string[];
|
||||
}
|
||||
|
||||
const ShipSearchForm = ({
|
||||
initialValues,
|
||||
things,
|
||||
isModalOpen,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: SearchShipProps) => {
|
||||
const intl = useIntl();
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const currentUser = initialState?.currentUserProfile;
|
||||
const [group_id, setGroupId] = useState<string | string[] | null>(null);
|
||||
|
||||
const [groupShips, setGroupShips] = useState<
|
||||
SgwModel.GroupShipResponse[] | []
|
||||
>([]);
|
||||
const [shipNames, setShipNames] = useState<string[]>([]);
|
||||
const [shipNameOptions, setShipNameOptions] = useState<
|
||||
{ value: string; key: number }[]
|
||||
>([]);
|
||||
const [shipRegNumberOptions, setShipRegNumberOptions] = useState<
|
||||
{ value: string; key: number }[]
|
||||
>([]);
|
||||
const formRef = useRef<ProFormInstance<SearchShipResponse>>();
|
||||
// console.log('InitialValue ', initialValues);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialValues === undefined && formRef.current) {
|
||||
formRef.current.resetFields();
|
||||
}
|
||||
}, [initialValues]);
|
||||
const [shipRegNumbers, setShipRegNumbers] = useState<string[]>([]);
|
||||
|
||||
const { shipTypes, getShipTypes } = useModel('slave.sgw.useShipTypes');
|
||||
|
||||
useEffect(() => {
|
||||
if (!shipTypes) {
|
||||
getShipTypes();
|
||||
}
|
||||
}, [shipTypes]);
|
||||
|
||||
const getListShipNames = () => {
|
||||
if (things && Array.isArray(things)) {
|
||||
const names = things
|
||||
.map((item) => item?.metadata?.ship_name)
|
||||
.filter((name): name is string => Boolean(name)); // bỏ null/undefined/"" nếu có
|
||||
setShipNames(names);
|
||||
}
|
||||
};
|
||||
const handleGroupSelect = (group: string | string[] | null) => {
|
||||
// console.log("Selected group key:", value);
|
||||
setGroupId(group);
|
||||
};
|
||||
|
||||
const handleSearchShipName = (value: string) => {
|
||||
if (!value) {
|
||||
setShipNameOptions([]); // không hiện gợi ý khi chưa nhập
|
||||
return;
|
||||
}
|
||||
const filtered = shipNames
|
||||
.filter((name) => name.toLowerCase().includes(value.toLowerCase()))
|
||||
.map((name, index) => ({ value: name, key: index }));
|
||||
setShipNameOptions(filtered);
|
||||
};
|
||||
|
||||
const getListShipRegNumbers = () => {
|
||||
if (things && Array.isArray(things)) {
|
||||
const regNumbers = things
|
||||
.map((item) => item?.metadata?.ship_reg_number)
|
||||
.filter((reg): reg is string => Boolean(reg)); // bỏ null/undefined/"" nếu có
|
||||
setShipRegNumbers(regNumbers);
|
||||
}
|
||||
};
|
||||
const handleSearchShipRegNumber = (value: string) => {
|
||||
if (!value) {
|
||||
setShipRegNumberOptions([]); // không hiện gợi ý khi chưa nhập
|
||||
return;
|
||||
}
|
||||
const filtered = shipRegNumbers
|
||||
.filter((regNumber) =>
|
||||
regNumber.toLowerCase().includes(value.toLowerCase()),
|
||||
)
|
||||
.map((regNumber, index) => ({ value: regNumber, key: index }));
|
||||
setShipRegNumberOptions(filtered);
|
||||
};
|
||||
|
||||
const getShipGroupByOwner = async () => {
|
||||
// console.log("Gọi hàm lấy ra đội tàu");
|
||||
|
||||
try {
|
||||
const groups = await apiGetShipGroups();
|
||||
setGroupShips(groups);
|
||||
// console.log("Groups: ", groups);
|
||||
} catch (error) {
|
||||
console.error('Error when get ShipGroup: ', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen) {
|
||||
getListShipNames();
|
||||
getListShipRegNumbers();
|
||||
if (currentUser?.metadata?.user_type === 'enduser') {
|
||||
getShipGroupByOwner();
|
||||
}
|
||||
}
|
||||
// console.log("InitialData: ", initialData);
|
||||
}, [isModalOpen, things]);
|
||||
|
||||
const alarmListLabel = [
|
||||
{
|
||||
label: 'Tiếp cận vùng hạn chế',
|
||||
value: '50:10',
|
||||
},
|
||||
{
|
||||
label: 'Đã ra (vào) vùng hạn chế)',
|
||||
value: '50:11',
|
||||
},
|
||||
{
|
||||
label: 'Đang đánh bắt trong vùng hạn chế',
|
||||
value: '50:12',
|
||||
},
|
||||
];
|
||||
// console.log(
|
||||
// 'ShipSearchForm render - isModalOpen:',
|
||||
// isModalOpen,
|
||||
// 'things length:',
|
||||
// things?.length,
|
||||
// );
|
||||
|
||||
return (
|
||||
<ModalForm
|
||||
title={
|
||||
<Flex align="center" justify="center">
|
||||
{intl.formatMessage({
|
||||
id: 'map.filter.name',
|
||||
defaultMessage: 'Filter',
|
||||
})}
|
||||
</Flex>
|
||||
}
|
||||
open={isModalOpen}
|
||||
onOpenChange={(open) => {
|
||||
// console.log('Modal onOpenChange:', open);
|
||||
if (!open) {
|
||||
onClose(false);
|
||||
}
|
||||
}}
|
||||
formRef={formRef}
|
||||
width={600}
|
||||
initialValues={initialValues}
|
||||
onFinish={async (values) => {
|
||||
// console.log("Values: ", values);
|
||||
// await onSubmit(values);
|
||||
const dataFinal: SearchShipResponse = {
|
||||
ship_name: values.ship_name,
|
||||
ship_length: values.ship_length,
|
||||
reg_number: values.reg_number,
|
||||
ship_power: values.ship_power,
|
||||
ship_type: values.ship_type,
|
||||
alarm_list: values.alarm_list,
|
||||
ship_group_id: values.ship_group_id,
|
||||
group_id: Array.isArray(group_id)
|
||||
? group_id.join(',')
|
||||
: group_id || undefined,
|
||||
};
|
||||
// console.log("Values: ", dataFinal);
|
||||
onSubmit(dataFinal);
|
||||
onClose(false);
|
||||
}}
|
||||
modalProps={{
|
||||
cancelText: (
|
||||
<FormattedMessage
|
||||
id="map.filter.cancel_button"
|
||||
defaultMessage="Cancel"
|
||||
/>
|
||||
),
|
||||
okText: (
|
||||
<FormattedMessage id="map.filter.find_button" defaultMessage="Find" />
|
||||
),
|
||||
}}
|
||||
layout="vertical"
|
||||
style={{ padding: '24px' }}
|
||||
>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={12}>
|
||||
<ProForm.Item
|
||||
name="ship_name"
|
||||
label={intl.formatMessage({
|
||||
id: 'map.filter.ship_name',
|
||||
defaultMessage: 'Ship Name',
|
||||
})}
|
||||
tooltip={intl.formatMessage({
|
||||
id: 'map.filter.ship_name_tooltip',
|
||||
defaultMessage: 'Find by Ship Name',
|
||||
})}
|
||||
fieldProps={{
|
||||
size: 'middle',
|
||||
style: { width: '100%' },
|
||||
}}
|
||||
>
|
||||
<AutoComplete
|
||||
placeholder="Hoàng Sa 001"
|
||||
style={{ width: '100%' }}
|
||||
size="middle"
|
||||
options={shipNameOptions}
|
||||
onSearch={handleSearchShipName}
|
||||
/>
|
||||
</ProForm.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<ProForm.Item
|
||||
name="reg_number"
|
||||
tooltip={intl.formatMessage({
|
||||
id: 'map.filter.ship_reg_number_tooltip',
|
||||
defaultMessage: 'Find by Ship Registration Number',
|
||||
})}
|
||||
label={intl.formatMessage({
|
||||
id: 'map.filter.ship_reg_number',
|
||||
defaultMessage: 'Ship Registration Number',
|
||||
})}
|
||||
fieldProps={{
|
||||
size: 'middle',
|
||||
style: { width: '100%' },
|
||||
}}
|
||||
>
|
||||
<AutoComplete
|
||||
placeholder="VN-00001"
|
||||
style={{ width: '100%' }}
|
||||
size="middle"
|
||||
options={shipRegNumberOptions}
|
||||
onSearch={handleSearchShipRegNumber}
|
||||
/>
|
||||
</ProForm.Item>
|
||||
</Col>
|
||||
<Col span={11} style={{ marginRight: 10 }}>
|
||||
<ProFormSlider
|
||||
range
|
||||
tooltip
|
||||
name="ship_length"
|
||||
label={intl.formatMessage({
|
||||
id: 'map.filter.ship_length',
|
||||
defaultMessage: 'Ship Length (m)',
|
||||
})}
|
||||
marks={{
|
||||
0: '0m',
|
||||
50: '50m',
|
||||
100: '100m',
|
||||
}}
|
||||
fieldProps={{
|
||||
style: { width: '80%' },
|
||||
tooltip: {
|
||||
formatter: (value) => `${value}m`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={11} style={{ marginLeft: 10 }}>
|
||||
<ProFormSlider
|
||||
range
|
||||
name="ship_power"
|
||||
label={intl.formatMessage({
|
||||
id: 'map.filter.ship_power',
|
||||
defaultMessage: 'Ship Power (kW)',
|
||||
})}
|
||||
max={100000}
|
||||
marks={{
|
||||
0: '0kW',
|
||||
50000: '50,000kW',
|
||||
100000: '100,000kW',
|
||||
}}
|
||||
fieldProps={{
|
||||
style: { width: '80%' },
|
||||
tooltip: {
|
||||
formatter: (value) => `${value}kW`,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={11}>
|
||||
<ProForm.Item
|
||||
name="ship_type"
|
||||
label={intl.formatMessage({
|
||||
id: 'map.filter.ship_type',
|
||||
defaultMessage: 'Ship Type',
|
||||
})}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'map.filter.ship_type_placeholder',
|
||||
defaultMessage: 'Select Ship Type',
|
||||
})}
|
||||
options={(Array.isArray(shipTypes) ? shipTypes : []).map(
|
||||
(type) => ({
|
||||
label: (
|
||||
<Tooltip title={type.description}>{type.name}</Tooltip>
|
||||
),
|
||||
value: type.id,
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</ProForm.Item>
|
||||
</Col>
|
||||
<Col span={13}>
|
||||
<ProForm.Item
|
||||
name="alarm_list"
|
||||
label={intl.formatMessage({
|
||||
id: 'map.filter.ship_warning',
|
||||
defaultMessage: 'Warning',
|
||||
})}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'map.filter.ship_warning_placeholder',
|
||||
defaultMessage: 'Select Warning',
|
||||
})}
|
||||
options={alarmListLabel}
|
||||
/>
|
||||
</ProForm.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={currentUser?.metadata?.user_type === 'enduser' ? 11 : 24}>
|
||||
<ProForm.Item
|
||||
name="group_id"
|
||||
label={intl.formatMessage({
|
||||
id: 'map.filter.area_type',
|
||||
defaultMessage: 'Area Type',
|
||||
})}
|
||||
>
|
||||
<TreeSelectedGroup
|
||||
groupIds={initialValues?.group_id}
|
||||
onSelected={handleGroupSelect}
|
||||
multiple
|
||||
/>
|
||||
</ProForm.Item>
|
||||
</Col>
|
||||
{currentUser?.metadata?.user_type === 'enduser' && (
|
||||
<Col span={13}>
|
||||
<ProForm.Item
|
||||
name="ship_group_id"
|
||||
label={intl.formatMessage({
|
||||
id: 'map.filter.ship_groups',
|
||||
defaultMessage: 'Ship Groups',
|
||||
})}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'map.filter.ship_groups_placeholder',
|
||||
defaultMessage: 'Select Ship Groups',
|
||||
})}
|
||||
options={groupShips?.map((group) => ({
|
||||
label: (
|
||||
<Tooltip title={group.description}> {group.name}</Tooltip>
|
||||
),
|
||||
value: group.id,
|
||||
}))}
|
||||
/>
|
||||
</ProForm.Item>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShipSearchForm;
|
||||
259
src/pages/Slave/SGW/Map/components/ShipTabs.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { formatDate } from '@/utils/slave/sgw/timeUtils';
|
||||
import { getTripState } from '@/utils/slave/sgw/tripUtils';
|
||||
import { ProDescriptions } from '@ant-design/pro-components';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { ShipDetailData } from '../type';
|
||||
|
||||
interface ShipSpecificationTabProps {
|
||||
ship: ShipDetailData | null;
|
||||
}
|
||||
|
||||
export const ShipSpecificationTab = ({ ship }: ShipSpecificationTabProps) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<ProDescriptions
|
||||
styles={{
|
||||
title: {
|
||||
marginBottom: -16,
|
||||
textAlign: 'center',
|
||||
marginTop: 10,
|
||||
fontSize: window.innerWidth <= 576 ? '14px' : '16px',
|
||||
},
|
||||
content: { paddingBottom: 1 },
|
||||
label: { paddingBottom: 1 },
|
||||
}}
|
||||
dataSource={{
|
||||
reg_number: ship?.ship?.reg_number || '-',
|
||||
imo_number: ship?.ship?.imo_number || '-',
|
||||
mmsi_number: ship?.ship?.mmsi_number || '-',
|
||||
ship_owner: ship?.owner?.metadata?.full_name || '-',
|
||||
ship_type: ship?.ship?.metadata?.ship_type || '-',
|
||||
ship_length: ship?.ship?.ship_length || '-',
|
||||
power_kw: ship?.ship?.ship_power ?? '-',
|
||||
fishing_license_number: ship?.ship?.fishing_license_number || '-',
|
||||
fishing_license_expiry_date:
|
||||
formatDate(ship?.ship?.fishing_license_expiry_date || '') ?? '-',
|
||||
}}
|
||||
column={{ xs: 1, sm: 2, md: 3 }}
|
||||
columns={[
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'map.ship_detail.reg_number',
|
||||
defaultMessage: 'Registration Number',
|
||||
}),
|
||||
key: 'reg_number',
|
||||
dataIndex: 'reg_number',
|
||||
copyable: true,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'map.ship_detail.imo_number',
|
||||
defaultMessage: 'IMO Number',
|
||||
}),
|
||||
key: 'imo_number',
|
||||
dataIndex: 'imo_number',
|
||||
copyable: true,
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'map.ship_detail.mmsi_number',
|
||||
defaultMessage: 'MMSI Number',
|
||||
}),
|
||||
key: 'mmsi_number',
|
||||
dataIndex: 'mmsi_number',
|
||||
copyable: true,
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'map.ship_detail.ship_owner',
|
||||
defaultMessage: 'Ship Owner',
|
||||
}),
|
||||
key: 'ship_owner',
|
||||
dataIndex: 'ship_owner',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'map.filter.ship_type',
|
||||
defaultMessage: 'Ship Type',
|
||||
}),
|
||||
key: 'ship_type',
|
||||
dataIndex: 'ship_type',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'map.filter.ship_power',
|
||||
defaultMessage: 'Power (kW)',
|
||||
}),
|
||||
key: 'power_kw',
|
||||
dataIndex: 'power_kw',
|
||||
render: (text) => `${text} kw`,
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'map.filter.ship_length',
|
||||
defaultMessage: 'Ship Length (m)',
|
||||
}),
|
||||
key: 'ship_length',
|
||||
dataIndex: 'ship_length',
|
||||
render: (text) => `${text} m`,
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'map.ship_detail.fishing_license',
|
||||
defaultMessage: 'Fishing License',
|
||||
}),
|
||||
tooltip: intl.formatMessage({
|
||||
id: 'map.ship_detail.fishing_license_number',
|
||||
defaultMessage: 'Fishing License Number',
|
||||
}),
|
||||
key: 'fishing_license_number',
|
||||
dataIndex: 'fishing_license_number',
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'map.ship_detail.fishing_license_expiry',
|
||||
defaultMessage: 'Expiry',
|
||||
}),
|
||||
tooltip: intl.formatMessage({
|
||||
id: 'map.ship_detail.fishing_license_expiry_message',
|
||||
defaultMessage: 'Fishing License Expiration Date',
|
||||
}),
|
||||
key: 'fishing_license_expiry_date',
|
||||
dataIndex: 'fishing_license_expiry_date',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShipTripInfoTab = ({ ship }: ShipSpecificationTabProps) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<>
|
||||
<ProDescriptions
|
||||
styles={{
|
||||
title: {
|
||||
marginBottom: -16,
|
||||
textAlign: 'center',
|
||||
fontSize: window.innerWidth <= 576 ? '14px' : '16px',
|
||||
},
|
||||
content: { paddingBottom: 1 },
|
||||
label: { paddingBottom: 1 },
|
||||
}}
|
||||
/>
|
||||
<ProDescriptions column={2} style={{ marginBottom: 10 }}>
|
||||
<ProDescriptions.Item
|
||||
label={intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_name',
|
||||
defaultMessage: 'Trip Name',
|
||||
})}
|
||||
>
|
||||
{ship?.ship?.metadata?.trip_name || '-'}
|
||||
</ProDescriptions.Item>
|
||||
<ProDescriptions.Item
|
||||
label={intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_state',
|
||||
defaultMessage: 'Status',
|
||||
})}
|
||||
>
|
||||
{getTripState(ship?.ship?.metadata?.trip_state) || '-'}
|
||||
</ProDescriptions.Item>
|
||||
</ProDescriptions>
|
||||
|
||||
{/* Hàng 2 + Hàng 3 */}
|
||||
<ProDescriptions column={3} style={{ marginBottom: 10 }}>
|
||||
<ProDescriptions.Item
|
||||
label={intl.formatMessage({
|
||||
id: 'map.ship_detail.captain',
|
||||
defaultMessage: 'Captain',
|
||||
})}
|
||||
valueType={'text'}
|
||||
>
|
||||
{/* {ship?.ship?.metadata?.captain ?? '-'} */} -
|
||||
</ProDescriptions.Item>
|
||||
<ProDescriptions.Item
|
||||
ellipsis
|
||||
style={{ maxWidth: 200 }}
|
||||
tooltip={intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_departure_port_tooltip',
|
||||
defaultMessage: 'Departure Port',
|
||||
})}
|
||||
label={intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_departure_port',
|
||||
defaultMessage: 'Departure Port',
|
||||
})}
|
||||
>
|
||||
{ship?.ship?.metadata?.trip_depart_port || '-'}
|
||||
</ProDescriptions.Item>
|
||||
<ProDescriptions.Item
|
||||
tooltip={intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_departure_time_tooltip',
|
||||
defaultMessage: 'Departure Time',
|
||||
})}
|
||||
label={intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_departure_time',
|
||||
defaultMessage: 'Departure Time',
|
||||
})}
|
||||
>
|
||||
{ship?.ship?.metadata?.trip_departure_time
|
||||
? formatDate(ship?.ship?.metadata?.trip_departure_time)
|
||||
: '-'}
|
||||
</ProDescriptions.Item>
|
||||
<ProDescriptions.Item
|
||||
label={intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_crews',
|
||||
defaultMessage: 'Crews',
|
||||
})}
|
||||
valueType={'text'}
|
||||
>
|
||||
{ship?.ship?.metadata?.crew_count ?? '-'} người
|
||||
</ProDescriptions.Item>
|
||||
<ProDescriptions.Item
|
||||
ellipsis
|
||||
style={{ maxWidth: 200 }}
|
||||
tooltip={intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_arrival_port_tooltip',
|
||||
defaultMessage: 'Arrival Port',
|
||||
})}
|
||||
label={`${intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_arrival_port',
|
||||
defaultMessage: 'Arrival Port',
|
||||
})} ${
|
||||
ship?.ship.metadata?.trip_state !== 4
|
||||
? `(${intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_estimated',
|
||||
defaultMessage: 'Estimated',
|
||||
})})`
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{ship?.ship?.metadata?.trip_arrival_port || '-'}
|
||||
</ProDescriptions.Item>
|
||||
<ProDescriptions.Item
|
||||
ellipsis
|
||||
style={{ maxWidth: 200 }}
|
||||
tooltip={intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_arrival_time_tooltip',
|
||||
defaultMessage: 'Arrival Time',
|
||||
})}
|
||||
label={`${intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_arrival_time',
|
||||
defaultMessage: 'Arrival Time',
|
||||
})} ${
|
||||
ship?.ship.metadata?.trip_state !== 4
|
||||
? `(${intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_estimated',
|
||||
defaultMessage: 'Estimated',
|
||||
})})`
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{ship?.ship?.metadata?.trip_arrival_time
|
||||
? formatDate(ship?.ship?.metadata?.trip_arrival_time)
|
||||
: '-'}
|
||||
</ProDescriptions.Item>
|
||||
</ProDescriptions>
|
||||
</>
|
||||
);
|
||||
};
|
||||
168
src/pages/Slave/SGW/Map/components/ShipWarningList.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { InfoOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Button, Collapse, Flex, List, Typography } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import { ZoneAlarmParse } from '../type';
|
||||
|
||||
interface ShipWarningListProps {
|
||||
zoneEnteredList: ZoneAlarmParse[];
|
||||
zoneApproachingList: ZoneAlarmParse[];
|
||||
selectedZoneId: string | null;
|
||||
onZoneButtonClick: (zone: ZoneAlarmParse) => void;
|
||||
}
|
||||
|
||||
const ShipWarningList = ({
|
||||
zoneEnteredList,
|
||||
zoneApproachingList,
|
||||
selectedZoneId,
|
||||
onZoneButtonClick,
|
||||
}: ShipWarningListProps) => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<Collapse
|
||||
ghost
|
||||
defaultActiveKey={
|
||||
zoneEnteredList.length > 0 && zoneApproachingList.length === 0
|
||||
? ['1']
|
||||
: zoneApproachingList.length > 0 && zoneEnteredList.length === 0
|
||||
? ['2']
|
||||
: []
|
||||
}
|
||||
expandIconPosition="end"
|
||||
items={[
|
||||
...(zoneEnteredList.length > 0
|
||||
? [
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<Flex justify="center">
|
||||
<Typography.Text keyboard type="danger" strong>
|
||||
🚫{' '}
|
||||
{intl.formatMessage({
|
||||
id: 'map.ship_detail.warning_title',
|
||||
defaultMessage: 'Warning',
|
||||
})}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
children: (
|
||||
<List
|
||||
size="small"
|
||||
style={{ padding: '10px 0px' }}
|
||||
dataSource={zoneEnteredList}
|
||||
renderItem={(item) => (
|
||||
<List.Item style={{ marginBottom: 4 }}>
|
||||
<List.Item.Meta
|
||||
title={`${item.zone_name} - ${item.message}`}
|
||||
description={`${intl.formatMessage({
|
||||
id: 'map.ship_detail.speed',
|
||||
defaultMessage: 'Speed',
|
||||
})}: ${item.s}km/h - ${intl.formatMessage({
|
||||
id: 'map.ship_detail.heading',
|
||||
defaultMessage: 'Heading',
|
||||
})}: ${item.h}° - ${intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_departure_time',
|
||||
defaultMessage: 'Time',
|
||||
})}: ${dayjs
|
||||
.unix(item?.gps_time || 0)
|
||||
.format('YYYY-MM-DD HH:mm:ss')}`}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
shape="circle"
|
||||
type="default"
|
||||
style={{
|
||||
backgroundColor:
|
||||
selectedZoneId === item.zone_id
|
||||
? '#DC143C'
|
||||
: undefined,
|
||||
color:
|
||||
selectedZoneId === item.zone_id
|
||||
? '#fff'
|
||||
: undefined,
|
||||
borderColor:
|
||||
selectedZoneId === item.zone_id
|
||||
? '#DC143C'
|
||||
: undefined,
|
||||
}}
|
||||
onClick={() => onZoneButtonClick(item)}
|
||||
icon={<WarningOutlined />}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(zoneApproachingList.length > 0
|
||||
? [
|
||||
{
|
||||
key: '2',
|
||||
label: (
|
||||
<Flex justify="center">
|
||||
<Typography.Text keyboard type="warning" strong>
|
||||
⚠️{' '}
|
||||
{intl.formatMessage({
|
||||
id: 'map.ship_detail.approaching_title',
|
||||
defaultMessage: 'Approaching Restricted Area',
|
||||
})}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
children: (
|
||||
<List
|
||||
size="small"
|
||||
style={{ padding: '10px 0px' }}
|
||||
dataSource={zoneApproachingList}
|
||||
renderItem={(item) => (
|
||||
<List.Item style={{ marginBottom: 4 }}>
|
||||
<List.Item.Meta
|
||||
title={`${item.zone_name} - ${item.message}`}
|
||||
description={`${intl.formatMessage({
|
||||
id: 'map.ship_detail.speed',
|
||||
defaultMessage: 'Speed',
|
||||
})}: ${item.s}km/h - ${intl.formatMessage({
|
||||
id: 'map.ship_detail.heading',
|
||||
defaultMessage: 'Heading',
|
||||
})}: ${item.h}° - ${intl.formatMessage({
|
||||
id: 'map.ship_detail.trip_departure_time',
|
||||
defaultMessage: 'Time',
|
||||
})}: ${dayjs
|
||||
.unix(item?.gps_time || 0)
|
||||
.format('YYYY-MM-DD HH:mm:ss')}`}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
shape="circle"
|
||||
type="default"
|
||||
style={{
|
||||
backgroundColor:
|
||||
selectedZoneId === item.zone_id
|
||||
? '#FF714B'
|
||||
: undefined,
|
||||
color:
|
||||
selectedZoneId === item.zone_id
|
||||
? '#fff'
|
||||
: undefined,
|
||||
borderColor:
|
||||
selectedZoneId === item.zone_id
|
||||
? '#FF714B'
|
||||
: undefined,
|
||||
}}
|
||||
onClick={() => onZoneButtonClick(item)}
|
||||
icon={<InfoOutlined />}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShipWarningList;
|
||||
95
src/pages/Slave/SGW/Map/components/SosOverlay.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { AlertFilled, ClockCircleOutlined } from '@ant-design/icons';
|
||||
import { Typography } from 'antd';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface SosOverlayContentProps {
|
||||
shipName?: string;
|
||||
reason?: string;
|
||||
time?: string;
|
||||
}
|
||||
|
||||
const SosOverlayContent = ({
|
||||
shipName,
|
||||
reason,
|
||||
time,
|
||||
}: SosOverlayContentProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.95)',
|
||||
border: '2px solid #ff4d4f',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 12px',
|
||||
minWidth: '180px',
|
||||
boxShadow: '0 4px 12px rgba(255, 77, 79, 0.3)',
|
||||
animation: 'pulse 2s infinite',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<AlertFilled style={{ color: '#ff4d4f', fontSize: '16px' }} />
|
||||
<Text strong style={{ color: '#ff4d4f', fontSize: '14px' }}>
|
||||
SOS CẢNH BÁO
|
||||
</Text>
|
||||
</div>
|
||||
{shipName && (
|
||||
<div style={{ marginBottom: '2px' }}>
|
||||
<Text style={{ fontSize: '12px' }}>
|
||||
<Text strong>Tàu:</Text> {shipName}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{reason && (
|
||||
<div style={{ marginBottom: '2px' }}>
|
||||
<Text style={{ fontSize: '12px' }}>
|
||||
<Text strong>Lý do:</Text> {reason}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
{time && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
|
||||
<ClockCircleOutlined style={{ fontSize: '12px', color: '#999' }} />
|
||||
<Text style={{ fontSize: '11px', color: '#666' }}>{time}</Text>
|
||||
</div>
|
||||
)}
|
||||
<style>{`
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 4px 12px rgba(255, 77, 79, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 20px rgba(255, 77, 79, 0.6);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render SOS Overlay vào một HTML element
|
||||
* @param element - HTML element target
|
||||
* @param props - Props cho SosOverlayContent
|
||||
* @returns Hàm cleanup để unmount React root
|
||||
*/
|
||||
export function renderSosOverlay(
|
||||
element: HTMLElement,
|
||||
props: SosOverlayContentProps,
|
||||
): () => void {
|
||||
const root = createRoot(element);
|
||||
root.render(<SosOverlayContent {...props} />);
|
||||
|
||||
return () => {
|
||||
root.unmount();
|
||||
};
|
||||
}
|
||||
|
||||
export default SosOverlayContent;
|
||||
309
src/pages/Slave/SGW/Map/components/VietNamMap.tsx
Normal file
@@ -0,0 +1,309 @@
|
||||
import { getTheme } from '@/components/Theme/ThemeSwitcher';
|
||||
import { useToken } from '@ant-design/pro-components';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Checkbox, Flex } from 'antd';
|
||||
import BaseLayer from 'ol/layer/Base';
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
INITIAL_VIEW_CONFIG,
|
||||
LAYERS,
|
||||
wmsBoundaryLineLayer,
|
||||
wmsEntryBanzoneLayer,
|
||||
wmsFishingBanzoneLayer,
|
||||
wmsPortsLayer,
|
||||
wmsTinhLayer,
|
||||
} from '../config/MapConfig';
|
||||
import { BaseMap } from './BaseMap';
|
||||
|
||||
export interface VietNamMapProps {
|
||||
style?: React.CSSProperties;
|
||||
onFeatureClick?: (feature: any) => void;
|
||||
onFeaturesClick?: (features: any[]) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onMapReady?: (baseMap: BaseMap) => void;
|
||||
}
|
||||
|
||||
export interface VietNamMapRef {
|
||||
baseMap: BaseMap | null;
|
||||
getBaseMap: () => BaseMap | null;
|
||||
}
|
||||
|
||||
interface LayerInfo {
|
||||
name: string;
|
||||
layer: BaseLayer;
|
||||
visible: boolean;
|
||||
mandatory: boolean;
|
||||
}
|
||||
|
||||
const VietNamMap = forwardRef<VietNamMapRef, VietNamMapProps>(
|
||||
(
|
||||
{ style = {}, onFeatureClick, onFeaturesClick, onError, onMapReady },
|
||||
ref,
|
||||
) => {
|
||||
const token = useToken();
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const baseMapRef = useRef<BaseMap | null>(null);
|
||||
const [layers, setLayers] = useState<LayerInfo[]>([]);
|
||||
const intl = useIntl();
|
||||
const [isDark, setIsDark] = useState(getTheme() === 'dark');
|
||||
const [isMapReady, setIsMapReady] = useState(false);
|
||||
|
||||
// Expose the BaseMap instance through ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
baseMap: baseMapRef.current,
|
||||
getBaseMap: () => baseMapRef.current,
|
||||
}));
|
||||
|
||||
// Listen for theme change
|
||||
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,
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update base layer based on theme
|
||||
useEffect(() => {
|
||||
const baseMap = baseMapRef.current;
|
||||
|
||||
if (!baseMap || !isMapReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
const baseLayerType = isDark ? 'dark' : 'osm';
|
||||
|
||||
baseMap.setBaseLayer(baseLayerType);
|
||||
}, [isDark, isMapReady]);
|
||||
|
||||
const getLayerDisplayName = (layerKey: string) => {
|
||||
switch (layerKey) {
|
||||
case LAYERS.FISHING_BANZONE.name:
|
||||
return intl.formatMessage({
|
||||
id: 'map.layer.fishing_ban_zone',
|
||||
defaultMessage: 'Vùng cấm đánh bắt cá',
|
||||
});
|
||||
case LAYERS.ENTRY_BANZONE.name:
|
||||
return intl.formatMessage({
|
||||
id: 'map.layer.entry_ban_zone',
|
||||
defaultMessage: 'Vùng cấm nhập cảnh',
|
||||
});
|
||||
case LAYERS.BOUNDARY_LINES.name:
|
||||
return intl.formatMessage({
|
||||
id: 'map.layer.boundary_lines',
|
||||
defaultMessage: 'Đường biên giới',
|
||||
});
|
||||
case LAYERS.EXIT_BANZONE.name:
|
||||
return intl.formatMessage({
|
||||
id: 'map.layer.exit_ban_zone',
|
||||
defaultMessage: 'Vùng cấm xuất cảnh',
|
||||
});
|
||||
case LAYERS.PORTS.name:
|
||||
return intl.formatMessage({
|
||||
id: 'map.layer.ports',
|
||||
defaultMessage: 'Cảng cá',
|
||||
});
|
||||
case LAYERS.TINH.name:
|
||||
return intl.formatMessage({
|
||||
id: 'map.layer.tinh',
|
||||
defaultMessage: 'Tỉnh',
|
||||
});
|
||||
default:
|
||||
return layerKey;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log('Rerender');
|
||||
|
||||
// Initialize BaseMap instance
|
||||
if (!baseMapRef.current && mapRef.current) {
|
||||
const baseMap = new BaseMap();
|
||||
baseMapRef.current = baseMap;
|
||||
|
||||
try {
|
||||
// Initialize the map
|
||||
baseMap.initMap(mapRef.current);
|
||||
|
||||
// Set initial view to Vietnam
|
||||
baseMap.setView(
|
||||
INITIAL_VIEW_CONFIG.center as [number, number],
|
||||
INITIAL_VIEW_CONFIG.zoom,
|
||||
);
|
||||
|
||||
// Add WMS layers
|
||||
const layerInfos: LayerInfo[] = [];
|
||||
|
||||
// Tỉnh layer (mandatory)
|
||||
wmsTinhLayer.setProperties({
|
||||
id: LAYERS.TINH.name,
|
||||
name: LAYERS.TINH.name,
|
||||
});
|
||||
wmsTinhLayer.setVisible(true);
|
||||
baseMap.addLayer(wmsTinhLayer);
|
||||
layerInfos.push({
|
||||
name: LAYERS.TINH.name,
|
||||
layer: wmsTinhLayer,
|
||||
visible: true,
|
||||
mandatory: true,
|
||||
});
|
||||
// console.log('Added Tỉnh layer with ID:', LAYERS.TINH.name);
|
||||
|
||||
// Optional layers
|
||||
const optionalLayers = [
|
||||
{ layer: wmsEntryBanzoneLayer, config: LAYERS.ENTRY_BANZONE },
|
||||
{ layer: wmsBoundaryLineLayer, config: LAYERS.BOUNDARY_LINES },
|
||||
{ layer: wmsFishingBanzoneLayer, config: LAYERS.FISHING_BANZONE },
|
||||
{ layer: wmsPortsLayer, config: LAYERS.PORTS },
|
||||
];
|
||||
|
||||
optionalLayers.forEach(({ layer, config }) => {
|
||||
layer.setProperties({
|
||||
id: config.name,
|
||||
name: config.name,
|
||||
});
|
||||
layer.setVisible(true);
|
||||
baseMap.addLayer(layer);
|
||||
layerInfos.push({
|
||||
name: config.name,
|
||||
layer: layer,
|
||||
visible: true,
|
||||
mandatory: false,
|
||||
});
|
||||
// console.log(`Added layer with ID: ${config.name}`);
|
||||
});
|
||||
|
||||
setLayers(layerInfos);
|
||||
|
||||
// Register click handler
|
||||
baseMap.onClick((feature) => {
|
||||
if (feature) {
|
||||
if (onFeatureClick) {
|
||||
onFeatureClick(feature);
|
||||
}
|
||||
}
|
||||
});
|
||||
baseMap.onClickMultiple((features) => {
|
||||
if (features && features.length > 0) {
|
||||
if (onFeaturesClick) {
|
||||
onFeaturesClick(features);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// // Register pointer move handler for hover effects
|
||||
// baseMap.onPointerMove((feature) => {
|
||||
// if (feature && onFeatureSelect) {
|
||||
// onFeatureSelect(feature);
|
||||
// }
|
||||
// });
|
||||
|
||||
// Call onMapReady with the BaseMap instance
|
||||
if (onMapReady) {
|
||||
onMapReady(baseMap);
|
||||
}
|
||||
|
||||
// Map is now ready
|
||||
setIsMapReady(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize map:', error);
|
||||
if (onError) {
|
||||
onError(error as Error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (baseMapRef.current) {
|
||||
baseMapRef.current.destroyMap();
|
||||
baseMapRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle layer visibility toggle
|
||||
const handleToggleLayer = (layerName: string, visible: boolean) => {
|
||||
const baseMap = baseMapRef.current;
|
||||
|
||||
if (!baseMap) return;
|
||||
|
||||
baseMap.toggleLayer(layerName, visible);
|
||||
setLayers((prevLayers) =>
|
||||
prevLayers.map((layer) =>
|
||||
layer.name === layerName ? { ...layer, visible } : layer,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
{/* Layer control panel */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
background: token.token.colorBgContainer,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
}}
|
||||
>
|
||||
<h3 className="mb-3 font-semibold text-center">
|
||||
{intl.formatMessage({
|
||||
id: 'map.layer.list',
|
||||
defaultMessage: 'Danh sách khu vực',
|
||||
})}
|
||||
</h3>
|
||||
<Flex vertical gap="small">
|
||||
{layers.map(
|
||||
(layer) =>
|
||||
!layer.mandatory && (
|
||||
<Checkbox
|
||||
key={layer.name}
|
||||
checked={layer.visible}
|
||||
onChange={(e) =>
|
||||
handleToggleLayer(layer.name, e.target.checked)
|
||||
}
|
||||
>
|
||||
{getLayerDisplayName(layer.name)}
|
||||
</Checkbox>
|
||||
),
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
{/* Map container */}
|
||||
<div
|
||||
ref={mapRef}
|
||||
style={{
|
||||
...style,
|
||||
height: '100vh', // Default height
|
||||
width: '100vw',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
VietNamMap.displayName = 'VietNamMap';
|
||||
|
||||
export default VietNamMap;
|
||||
117
src/pages/Slave/SGW/Map/config/MapConfig.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import {
|
||||
STATUS_DANGEROUS,
|
||||
STATUS_NORMAL,
|
||||
STATUS_SOS,
|
||||
STATUS_WARNING,
|
||||
} from '@/constants';
|
||||
import { createWmsLayer } from '@/utils/slave/sgw/mapUtils';
|
||||
import TileLayer from 'ol/layer/Tile';
|
||||
import { XYZ } from 'ol/source';
|
||||
|
||||
export const GEOSERVER_URL = '/geoserver'; // Sử dụng proxy của CRA
|
||||
export const WORKSPACE = 'vn_gadm_shp';
|
||||
|
||||
export const PROJECTION = 'EPSG:3857';
|
||||
export const LAYERS = {
|
||||
TINH: {
|
||||
name: 'DiaPhan_Tinh_2025',
|
||||
wmsVisibleUpTo: 9.99,
|
||||
vectorVisibleUpTo: 9.99,
|
||||
nameProperty: 'tenTinh',
|
||||
// Các thuộc tính sẽ hiển thị trong popup
|
||||
// Key: Nhãn hiển thị, Value: Tên thuộc tính trong dữ liệu
|
||||
popupProperties: { 'Dân số': 'danSo', 'Diện tích (km²)': 'dienTich' },
|
||||
},
|
||||
XA: {
|
||||
name: 'DiaPhan_Xa_2025',
|
||||
wmsVisibleFrom: 10,
|
||||
vectorVisibleFrom: 10,
|
||||
nameProperty: 'tenXa',
|
||||
// Các thuộc tính sẽ hiển thị trong popup
|
||||
// Key: Nhãn hiển thị, Value: Tên thuộc tính trong dữ liệu
|
||||
popupProperties: { 'Dân số': 'danSo', 'Diện tích (km²)': 'dienTich' },
|
||||
},
|
||||
FISHING_BANZONE: {
|
||||
name: 'fishing_ban_zones',
|
||||
wmsVisibleFrom: 10,
|
||||
vectorVisibleFrom: 10,
|
||||
nameProperty: 'name',
|
||||
popupProperties: {},
|
||||
},
|
||||
ENTRY_BANZONE: {
|
||||
name: 'entry_ban_zones',
|
||||
wmsVisibleFrom: 10,
|
||||
vectorVisibleFrom: 10,
|
||||
nameProperty: 'name',
|
||||
popupProperties: {},
|
||||
},
|
||||
BOUNDARY_LINES: {
|
||||
name: 'boundary_lines',
|
||||
wmsVisibleFrom: 10,
|
||||
vectorVisibleFrom: 10,
|
||||
nameProperty: 'name',
|
||||
popupProperties: {},
|
||||
},
|
||||
EXIT_BANZONE: {
|
||||
name: 'exit_ban_zones',
|
||||
wmsVisibleFrom: 10,
|
||||
vectorVisibleFrom: 10,
|
||||
nameProperty: 'name',
|
||||
popupProperties: {},
|
||||
},
|
||||
PORTS: {
|
||||
name: 'ports',
|
||||
wmsVisibleFrom: 10,
|
||||
vectorVisibleFrom: 10,
|
||||
nameProperty: 'name',
|
||||
popupProperties: {},
|
||||
},
|
||||
};
|
||||
|
||||
export const BASEMAP_URL =
|
||||
'https://cartodb-basemaps-a.global.ssl.fastly.net/light_nolabels/{z}/{x}/{y}.png';
|
||||
export const BASEMAP_ATTRIBUTIONS = '© OpenStreetMap contributors, © CartoDB';
|
||||
|
||||
export const INITIAL_VIEW_CONFIG = {
|
||||
// Dịch tâm bản đồ ra phía biển và zoom ra xa hơn để thấy bao quát
|
||||
center: [116.152685, 15.70581],
|
||||
zoom: 6.5,
|
||||
minZoom: 5,
|
||||
maxZoom: 12,
|
||||
minScale: 0.1,
|
||||
maxScale: 0.4,
|
||||
};
|
||||
|
||||
export const osmLayer = new TileLayer({
|
||||
source: new XYZ({
|
||||
url: BASEMAP_URL,
|
||||
attributions: BASEMAP_ATTRIBUTIONS,
|
||||
}),
|
||||
});
|
||||
|
||||
export const wmsTinhLayer = createWmsLayer(LAYERS.TINH.name);
|
||||
|
||||
export const wmsXaLayer = createWmsLayer(LAYERS.XA.name);
|
||||
|
||||
export const wmsEntryBanzoneLayer = createWmsLayer(LAYERS.ENTRY_BANZONE.name);
|
||||
export const wmsBoundaryLineLayer = createWmsLayer(LAYERS.BOUNDARY_LINES.name);
|
||||
export const wmsFishingBanzoneLayer = createWmsLayer(
|
||||
LAYERS.FISHING_BANZONE.name,
|
||||
);
|
||||
export const wmsExitBanzoneLayer = createWmsLayer(LAYERS.EXIT_BANZONE.name);
|
||||
export const wmsPortsLayer = createWmsLayer(LAYERS.PORTS.name);
|
||||
|
||||
export const getShipNameColor = (status: number) => {
|
||||
switch (status) {
|
||||
case STATUS_NORMAL:
|
||||
return 'green';
|
||||
case STATUS_WARNING:
|
||||
return 'orange';
|
||||
case STATUS_DANGEROUS:
|
||||
return 'red';
|
||||
case STATUS_SOS:
|
||||
return 'red';
|
||||
default:
|
||||
return 'black';
|
||||
}
|
||||
};
|
||||
78
src/pages/Slave/SGW/Map/index.less
Normal file
@@ -0,0 +1,78 @@
|
||||
.italic {
|
||||
//font-style: italic;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.disconnected {
|
||||
color: rgb(146, 143, 143);
|
||||
//background-color: rgb(219, 220, 222);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(.cursor-pointer-row .ant-table-tbody > tr) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.normalActive {
|
||||
color: #52c41a;
|
||||
background: #e0fec3;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
|
||||
.warningActive {
|
||||
color: #faad14;
|
||||
background: #f8ebaa;
|
||||
border-color: #ffe58f;
|
||||
}
|
||||
|
||||
.criticalActive {
|
||||
color: #ff4d4f;
|
||||
background: #f9b9b0;
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
|
||||
.normal {
|
||||
color: #52c41a;
|
||||
background: #fff;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: #faad14;
|
||||
background: #fff;
|
||||
border-color: #ffe58f;
|
||||
}
|
||||
|
||||
.critical {
|
||||
color: #ff4d4f;
|
||||
background: #fff;
|
||||
border-color: #ffccc7;
|
||||
}
|
||||
|
||||
.offline {
|
||||
background: #fff;
|
||||
color: rgba(0, 0, 0, 88%);
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.offlineActive {
|
||||
background: rgb(190, 190, 190);
|
||||
color: rgba(0, 0, 0, 88%);
|
||||
border-color: #d9d9d9;
|
||||
}
|
||||
|
||||
.online {
|
||||
background: #fff;
|
||||
color: #1677ff;
|
||||
border-color: #91caff;
|
||||
}
|
||||
|
||||
.onlineActive {
|
||||
background: #c6e1f5;
|
||||
color: #1677ff;
|
||||
border-color: #91caff;
|
||||
}
|
||||
|
||||
.table-row-select tbody tr:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -1,11 +1,645 @@
|
||||
import React from 'react';
|
||||
import { getBadgeStatus } from '@/components/shared/ThingShared';
|
||||
import { DURATION_POLLING_PRESENTATIONS } from '@/constants';
|
||||
import useGetShipSos from '@/models/slave/sgw/useShipSos';
|
||||
import { apiSearchThings } from '@/services/master/ThingController';
|
||||
import { formatUnixTime } from '@/utils/slave/sgw/timeUtils';
|
||||
import { DownOutlined, FilterOutlined, UpOutlined } from '@ant-design/icons';
|
||||
import type { ActionType, ProColumns } from '@ant-design/pro-components';
|
||||
import { ParamsType, useToken } from '@ant-design/pro-components';
|
||||
import ProTable from '@ant-design/pro-table';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Flex,
|
||||
message,
|
||||
notification,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { Feature } from 'ol';
|
||||
import VectorLayer from 'ol/layer/Vector';
|
||||
import VectorSource from 'ol/source/Vector';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import TagState from '../../../../components/shared/TagState';
|
||||
import { BaseMap } from './components/BaseMap';
|
||||
import MultipleShips from './components/MultipleShips';
|
||||
import ShipDetail, { ShipDetailMessageAction } from './components/ShipDetail';
|
||||
import ShipSearchForm, {
|
||||
SearchShipResponse,
|
||||
} from './components/ShipSearchForm';
|
||||
import { renderSosOverlay } from './components/SosOverlay';
|
||||
import VietNamMap, { VietNamMapRef } from './components/VietNamMap';
|
||||
import { getShipNameColor } from './config/MapConfig';
|
||||
import styles from './index.less';
|
||||
import {
|
||||
DATA_LAYER,
|
||||
GPSParseResult,
|
||||
TagStateCallbackPayload,
|
||||
TEMPORARY_LAYER,
|
||||
} from './type';
|
||||
|
||||
const MapPage = () => {
|
||||
// Create a ref for VietNamMap to access BaseMap methods
|
||||
const vietNamMapRef = useRef<VietNamMapRef>(null);
|
||||
const baseMap = useRef<BaseMap | null>(null);
|
||||
// Lưu các cancel functions của sosPulse để cleanup
|
||||
const sosPulseCancelRefs = useRef<(() => void)[]>([]);
|
||||
// Lưu các cleanup functions của overlay để cleanup
|
||||
const overlayCleanupRefs = useRef<Map<string, () => void>>(new Map());
|
||||
const [stateQuery, setStateQuery] = useState<
|
||||
TagStateCallbackPayload | undefined
|
||||
>(undefined);
|
||||
const token = useToken();
|
||||
const intl = useIntl();
|
||||
const [things, setThings] = useState<SgwModel.SgwThingsResponse | null>(null);
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(true);
|
||||
const tableRef = useRef<ActionType>();
|
||||
const [notificationApi, contextHolder] = notification.useNotification();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [showMultiShipsDrawer, setShowMultiShipsDrawer] =
|
||||
useState<boolean>(false);
|
||||
const [multipleThingsSelected, setMultipleThingsSelected] = useState<
|
||||
SgwModel.SgwThing[]
|
||||
>([]);
|
||||
const [formSearchData, setFormSearchData] =
|
||||
useState<SearchShipResponse | null>(null);
|
||||
const [openFormSearch, setOpenFormSearch] = useState(false);
|
||||
const { shipSos, getShipSosWs } = useGetShipSos();
|
||||
|
||||
const handleClickOneThing = (thing: SgwModel.SgwThing) => {
|
||||
if (baseMap.current === null) {
|
||||
console.log('BaseMap is not ready yet');
|
||||
return;
|
||||
}
|
||||
notificationApi.open({
|
||||
message: (
|
||||
<Flex justify="center">
|
||||
<Typography.Title level={4}>{`${intl.formatMessage({
|
||||
id: 'map.ship_detail.name',
|
||||
defaultMessage: 'Device Information',
|
||||
})} ${thing.name}`}</Typography.Title>
|
||||
</Flex>
|
||||
),
|
||||
key: `ship-detail-${thing.id}`,
|
||||
description: baseMap && (
|
||||
<ShipDetail
|
||||
thing={thing}
|
||||
messageApi={messageApi}
|
||||
mapController={baseMap.current}
|
||||
/>
|
||||
),
|
||||
placement: 'bottomRight',
|
||||
actions: <ShipDetailMessageAction />,
|
||||
duration: 100,
|
||||
onClose() {
|
||||
baseMap.current?.clearFeatures(TEMPORARY_LAYER);
|
||||
baseMap.current?.toggleLayer(DATA_LAYER, true);
|
||||
baseMap.current?.zoomToFeaturesInLayer(DATA_LAYER);
|
||||
},
|
||||
style: {
|
||||
width:
|
||||
window.innerWidth <= 576
|
||||
? '80vw'
|
||||
: window.innerWidth <= 768
|
||||
? '60vw'
|
||||
: '800px',
|
||||
maxWidth: '800px',
|
||||
height: 'auto',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onFeaturesClick = useCallback((features: Feature[]) => {
|
||||
console.log('Multiple features clicked:', features);
|
||||
const things: SgwModel.SgwThing[] = features.map((feature) =>
|
||||
feature.get('thing'),
|
||||
) as SgwModel.SgwThing[];
|
||||
setMultipleThingsSelected(things);
|
||||
setShowMultiShipsDrawer(true);
|
||||
}, []);
|
||||
|
||||
const onFeatureClick = useCallback(
|
||||
(feature: Feature) => {
|
||||
const thing: SgwModel.SgwThing = feature.get('thing');
|
||||
baseMap.current?.zoomToFeatures([feature]);
|
||||
handleClickOneThing(thing);
|
||||
},
|
||||
[baseMap],
|
||||
);
|
||||
|
||||
const onError = useCallback(
|
||||
(error: any) => {
|
||||
console.error(
|
||||
intl.formatMessage({ id: 'home.mapError' }),
|
||||
'at',
|
||||
new Date().toLocaleTimeString(),
|
||||
':',
|
||||
error,
|
||||
);
|
||||
},
|
||||
[intl],
|
||||
);
|
||||
|
||||
// Handler called when map is ready
|
||||
const handleMapReady = useCallback((baseMapInstance: BaseMap) => {
|
||||
// console.log('Map is ready!', baseMapInstance);
|
||||
baseMap.current = baseMapInstance;
|
||||
// Create a vector layer for dynamic features
|
||||
const vectorDataLayer = new VectorLayer({
|
||||
source: new VectorSource(),
|
||||
});
|
||||
vectorDataLayer.set('id', DATA_LAYER);
|
||||
const vectorTemporaryLayer = new VectorLayer({
|
||||
source: new VectorSource(),
|
||||
});
|
||||
vectorTemporaryLayer.set('id', TEMPORARY_LAYER);
|
||||
|
||||
baseMapInstance.addLayer(vectorDataLayer);
|
||||
baseMapInstance.addLayer(vectorTemporaryLayer);
|
||||
getShipSosWs();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (shipSos !== null && things && things.things) {
|
||||
const check = things?.things?.find(
|
||||
(thing) => thing.id === shipSos.thing_id,
|
||||
);
|
||||
if (check) {
|
||||
tableRef.current?.reload();
|
||||
}
|
||||
}
|
||||
}, [shipSos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formSearchData && tableRef.current) {
|
||||
tableRef.current.reload(); // gọi lại request
|
||||
}
|
||||
}, [formSearchData]);
|
||||
|
||||
const queryThings = async (params: ParamsType) => {
|
||||
try {
|
||||
const { current = 1, pageSize = 10, keyword = '' } = params;
|
||||
const offset = current === 1 ? 0 : (current - 1) * pageSize;
|
||||
const query: MasterModel.SearchThingPaginationBody = {
|
||||
offset: offset,
|
||||
limit: 200,
|
||||
order: 'name',
|
||||
dir: 'asc',
|
||||
};
|
||||
const stateNormalQuery = stateQuery?.isNormal ? 'normal' : '';
|
||||
const stateSosQuery = stateQuery?.isSos ? 'sos' : '';
|
||||
const stateWarningQuery = stateQuery?.isWarning
|
||||
? stateNormalQuery + ',warning'
|
||||
: stateNormalQuery;
|
||||
const stateCriticalQuery = stateQuery?.isCritical
|
||||
? stateWarningQuery + ',critical'
|
||||
: stateWarningQuery;
|
||||
const stateQueryParams =
|
||||
stateQuery?.isNormal &&
|
||||
stateQuery?.isWarning &&
|
||||
stateQuery?.isCritical &&
|
||||
stateQuery?.isSos
|
||||
? ''
|
||||
: [stateCriticalQuery, stateSosQuery].filter(Boolean).join(',');
|
||||
let metaFormQuery: Record<string, any> = {};
|
||||
if (stateQuery?.isDisconnected)
|
||||
metaFormQuery = { ...metaFormQuery, connected: false };
|
||||
const metaStateQuery =
|
||||
stateQueryParams !== '' ? { state_level: stateQueryParams } : null;
|
||||
if (formSearchData) {
|
||||
const {
|
||||
ship_name,
|
||||
reg_number,
|
||||
ship_length,
|
||||
ship_power,
|
||||
ship_type,
|
||||
alarm_list,
|
||||
group_id,
|
||||
ship_group_id,
|
||||
} = formSearchData;
|
||||
|
||||
if (ship_name) metaFormQuery.ship_name = ship_name;
|
||||
if (reg_number) metaFormQuery.ship_reg_number = reg_number;
|
||||
if (Array.isArray(ship_length) && ship_length.length === 2) {
|
||||
metaFormQuery.ship_length = ship_length;
|
||||
}
|
||||
if (Array.isArray(ship_power) && ship_power.length === 2) {
|
||||
metaFormQuery.ship_power = ship_power;
|
||||
}
|
||||
if (Array.isArray(ship_type) && ship_type.length > 0) {
|
||||
metaFormQuery.ship_type = ship_type;
|
||||
}
|
||||
if (Array.isArray(alarm_list) && alarm_list.length > 0) {
|
||||
metaFormQuery.alarm_list = alarm_list;
|
||||
}
|
||||
if (group_id) metaFormQuery.group_id = group_id;
|
||||
if (Array.isArray(ship_group_id) && ship_group_id.length > 0) {
|
||||
metaFormQuery.ship_group_id = ship_group_id;
|
||||
}
|
||||
}
|
||||
const metaQuery = {
|
||||
...query,
|
||||
metadata: {
|
||||
ship_name: keyword,
|
||||
...metaFormQuery,
|
||||
...metaStateQuery,
|
||||
not_empty: 'ship_id',
|
||||
},
|
||||
};
|
||||
|
||||
setThings(null);
|
||||
const resp = await apiSearchThings(metaQuery, 'sgw');
|
||||
return resp;
|
||||
} catch (error) {
|
||||
console.error('Error when searchThings: ', error);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomToFeature = (layerName: string) => {
|
||||
if (!baseMap) return;
|
||||
|
||||
// Get all features and zoom to them
|
||||
const vectorLayer = baseMap.current?.getLayerById(layerName);
|
||||
if (vectorLayer && vectorLayer instanceof VectorLayer) {
|
||||
const features = vectorLayer.getSource()?.getFeatures();
|
||||
if (features && features.length > 0) {
|
||||
baseMap.current?.zoomToFeatures(features);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!things || !baseMap.current || !things.things) return;
|
||||
// Cleanup các animation và overlay cũ trước khi render lại
|
||||
sosPulseCancelRefs.current.forEach((cancel) => cancel());
|
||||
sosPulseCancelRefs.current = [];
|
||||
overlayCleanupRefs.current.forEach((cleanup) => cleanup());
|
||||
overlayCleanupRefs.current.clear();
|
||||
baseMap.current.clearOverlays();
|
||||
|
||||
baseMap.current.clearFeatures(DATA_LAYER);
|
||||
for (const thing of things.things) {
|
||||
const gpsData: GPSParseResult = JSON.parse(thing.metadata?.gps || '{}');
|
||||
if (gpsData.lat && gpsData.lon) {
|
||||
if (thing.metadata?.state_level === 3) {
|
||||
console.log('Cos tau sos');
|
||||
const feature = baseMap.current?.addPoint(
|
||||
DATA_LAYER,
|
||||
[gpsData.lon, gpsData.lat],
|
||||
{
|
||||
thing,
|
||||
type: 'sos-point',
|
||||
},
|
||||
);
|
||||
// Bắt đầu hiệu ứng gợn sóng
|
||||
const cancel = baseMap.current?.sosPulse(feature, DATA_LAYER);
|
||||
if (cancel) {
|
||||
sosPulseCancelRefs.current.push(cancel);
|
||||
}
|
||||
// Tạo overlay hiển thị thông tin SOS
|
||||
const overlayElement = document.createElement('div');
|
||||
const cleanup = renderSosOverlay(overlayElement, {
|
||||
shipName: thing.metadata?.ship_name || thing.name,
|
||||
reason: thing.metadata.sos || 'Không rõ lý do',
|
||||
time: thing.metadata?.sos_time
|
||||
? formatUnixTime(thing.metadata.sos_time)
|
||||
: undefined,
|
||||
});
|
||||
overlayCleanupRefs.current.set(thing.id!, cleanup);
|
||||
baseMap.current.addOrUpdateOverlay(
|
||||
`sos-${thing.id}`,
|
||||
[gpsData.lon, gpsData.lat],
|
||||
overlayElement,
|
||||
);
|
||||
} else {
|
||||
baseMap.current?.addPoint(DATA_LAYER, [gpsData.lon, gpsData.lat], {
|
||||
thing,
|
||||
type: 'main-point',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
handleZoomToFeature(DATA_LAYER);
|
||||
// Cleanup khi component unmount
|
||||
return () => {
|
||||
sosPulseCancelRefs.current.forEach((cancel) => cancel());
|
||||
sosPulseCancelRefs.current = [];
|
||||
overlayCleanupRefs.current.forEach((cleanup) => cleanup());
|
||||
overlayCleanupRefs.current.clear();
|
||||
};
|
||||
}, [things]);
|
||||
|
||||
const columns: ProColumns<SgwModel.SgwThing>[] = [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'thing.name',
|
||||
defaultMessage: 'Name',
|
||||
}),
|
||||
dataIndex: ['metadata', 'ship_name'],
|
||||
key: 'ship_name',
|
||||
hideInSearch: true,
|
||||
width: '30%',
|
||||
ellipsis: true,
|
||||
render: (_, row) => {
|
||||
const text = row?.metadata?.ship_name || '';
|
||||
return (
|
||||
<Tooltip title={text}>
|
||||
<span
|
||||
style={{
|
||||
color: getShipNameColor(row?.metadata?.state_level || 0),
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'thing.status',
|
||||
defaultMessage: 'Status',
|
||||
}),
|
||||
dataIndex: ['metadata', 'state_level'],
|
||||
key: 'status',
|
||||
hideInSearch: true,
|
||||
width: '70%',
|
||||
filters: true,
|
||||
onFilter: true,
|
||||
valueType: 'select',
|
||||
valueEnum: {
|
||||
0: {
|
||||
text: intl.formatMessage({
|
||||
id: 'thing.status.normal',
|
||||
defaultMessage: 'Normal',
|
||||
}),
|
||||
status: 'Normal',
|
||||
},
|
||||
1: {
|
||||
text: intl.formatMessage({
|
||||
id: 'thing.status.warning',
|
||||
defaultMessage: 'Warning',
|
||||
}),
|
||||
status: 'Warning',
|
||||
},
|
||||
2: {
|
||||
text: intl.formatMessage({
|
||||
id: 'thing.status.critical',
|
||||
defaultMessage: 'Critical',
|
||||
}),
|
||||
status: 'Critical',
|
||||
},
|
||||
3: {
|
||||
text: intl.formatMessage({
|
||||
id: 'thing.status.sos',
|
||||
defaultMessage: 'SOS',
|
||||
}),
|
||||
status: 'SOS',
|
||||
},
|
||||
},
|
||||
ellipsis: true,
|
||||
render: (_, row) => {
|
||||
const alarm = JSON.parse(row?.metadata?.alarm_list || '{}');
|
||||
const text = alarm?.map((a: any) => a?.name).join(', ');
|
||||
return (
|
||||
<Tooltip placement="top" title={text}>
|
||||
{getBadgeStatus(row?.metadata?.state_level || 0)}
|
||||
<span className="ml-2 text-gray-500">{text}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const handleTagStateChange = (payload: TagStateCallbackPayload) => {
|
||||
setStateQuery(payload);
|
||||
tableRef.current?.reload();
|
||||
};
|
||||
|
||||
const SGWMap: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Bản đồ (SGW)</h1>
|
||||
<div className="h-screen w-screen relative">
|
||||
{contextHolder}
|
||||
{messageContextHolder}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 5,
|
||||
left: 2,
|
||||
zIndex: 10,
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
backgroundColor: token.token.colorBgContainer,
|
||||
width: isCollapsed ? '28%' : '30%',
|
||||
minWidth: '25%',
|
||||
maxWidth: '30%',
|
||||
overflowX: 'auto',
|
||||
whiteSpace: 'nowrap',
|
||||
transition: 'width 0.5s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<Flex
|
||||
justify="space-between"
|
||||
gap={30}
|
||||
align="center"
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
<Flex gap={8} align="center">
|
||||
<Button
|
||||
type="text"
|
||||
icon={isCollapsed ? <DownOutlined /> : <UpOutlined />}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
style={{ padding: '4px 8px' }}
|
||||
/>
|
||||
{formSearchData === null ? (
|
||||
<Button
|
||||
icon={<FilterOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
messageApi.destroy();
|
||||
setOpenFormSearch(true);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Tag
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
padding: '4px 8px',
|
||||
lineHeight: '20px',
|
||||
minWidth: '0',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
messageApi.destroy();
|
||||
setOpenFormSearch(true);
|
||||
}}
|
||||
closeIcon
|
||||
onClose={(e) => {
|
||||
e.stopPropagation();
|
||||
setFormSearchData(null);
|
||||
if (tableRef.current) {
|
||||
tableRef.current.reload();
|
||||
}
|
||||
}}
|
||||
color="#87d068"
|
||||
>
|
||||
Tìm theo bộ lọc
|
||||
</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
{isCollapsed && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<TagState
|
||||
normalCount={things?.metadata?.total_state_level_0 || 0}
|
||||
warningCount={things?.metadata?.total_state_level_1 || 0}
|
||||
criticalCount={things?.metadata?.total_state_level_2 || 0}
|
||||
sosCount={things?.metadata?.total_sos || 0}
|
||||
disconnectedCount={
|
||||
(things?.metadata?.total_thing ?? 0) -
|
||||
(things?.metadata?.total_connected ?? 0) || 0
|
||||
}
|
||||
onTagPress={handleTagStateChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Flex>
|
||||
<ProTable<SgwModel.SgwThing>
|
||||
tableClassName="table-row-select cursor-pointer-row"
|
||||
actionRef={tableRef}
|
||||
toolbar={{
|
||||
title: null,
|
||||
actions: [
|
||||
<TagState
|
||||
key="tag-state"
|
||||
normalCount={things?.metadata?.total_state_level_0 || 0}
|
||||
warningCount={things?.metadata?.total_state_level_1 || 0}
|
||||
criticalCount={things?.metadata?.total_state_level_2 || 0}
|
||||
sosCount={things?.metadata?.total_sos || 0}
|
||||
disconnectedCount={
|
||||
(things?.metadata?.total_thing ?? 0) -
|
||||
(things?.metadata?.total_connected ?? 0) || 0
|
||||
}
|
||||
onTagPress={handleTagStateChange}
|
||||
/>,
|
||||
],
|
||||
}}
|
||||
bordered={true}
|
||||
polling={DURATION_POLLING_PRESENTATIONS}
|
||||
style={{
|
||||
display: isCollapsed ? 'none' : 'block',
|
||||
}}
|
||||
pagination={{
|
||||
size: 'small',
|
||||
defaultPageSize: 10,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['10', '15', '20'],
|
||||
showTotal: (total, range) =>
|
||||
`${range[0]}-${range[1]} trên ${total} ${intl.formatMessage({
|
||||
id: 'thing.ships',
|
||||
defaultMessage: 'ships',
|
||||
})}`,
|
||||
}}
|
||||
columns={columns}
|
||||
request={async (params: ParamsType) => {
|
||||
const data = await queryThings(params);
|
||||
const sortedThings = [...(data.things || [])].sort((a, b) => {
|
||||
const stateLevelA = a.metadata?.state_level || 0;
|
||||
const stateLevelB = b.metadata?.state_level || 0;
|
||||
return stateLevelB - stateLevelA;
|
||||
});
|
||||
setThings(data);
|
||||
return Promise.resolve({
|
||||
success: true,
|
||||
data: sortedThings,
|
||||
total: data?.metadata?.total_filter || 0,
|
||||
});
|
||||
}}
|
||||
onRow={(row) => {
|
||||
return {
|
||||
onClick: () => {
|
||||
handleClickOneThing(row);
|
||||
},
|
||||
};
|
||||
}}
|
||||
rowClassName={(record) =>
|
||||
record?.metadata?.state_level
|
||||
? 'cursor-pointer'
|
||||
: `${styles.disconnected} cursor-pointer`
|
||||
}
|
||||
options={{
|
||||
search: false,
|
||||
setting: false,
|
||||
density: false,
|
||||
reload: true,
|
||||
}}
|
||||
defaultSize="small"
|
||||
search={false}
|
||||
rowKey="id"
|
||||
dateFormatter="string"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VietNamMap with ref */}
|
||||
<VietNamMap
|
||||
ref={vietNamMapRef}
|
||||
style={{
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
}}
|
||||
onMapReady={handleMapReady}
|
||||
onFeatureClick={onFeatureClick}
|
||||
onFeaturesClick={onFeaturesClick}
|
||||
onError={onError}
|
||||
/>
|
||||
<Drawer
|
||||
styles={{
|
||||
header: {
|
||||
padding: 5,
|
||||
},
|
||||
}}
|
||||
open={showMultiShipsDrawer}
|
||||
onClose={() => {
|
||||
setShowMultiShipsDrawer(false);
|
||||
setMultipleThingsSelected([]);
|
||||
}}
|
||||
title={
|
||||
<Flex align="center" justify="center">
|
||||
<Typography.Title level={4}>
|
||||
Thông tin {multipleThingsSelected.length} tàu
|
||||
</Typography.Title>
|
||||
</Flex>
|
||||
}
|
||||
size="large"
|
||||
>
|
||||
<MultipleShips
|
||||
things={multipleThingsSelected}
|
||||
messageApi={messageApi}
|
||||
mapController={baseMap.current}
|
||||
/>
|
||||
</Drawer>
|
||||
<ShipSearchForm
|
||||
initialValues={formSearchData || undefined}
|
||||
isModalOpen={openFormSearch}
|
||||
onClose={setOpenFormSearch}
|
||||
things={things?.things || []}
|
||||
onSubmit={(value: SearchShipResponse) => {
|
||||
setFormSearchData(value);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SGWMap;
|
||||
export default MapPage;
|
||||
|
||||
109
src/pages/Slave/SGW/Map/type.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import BaseLayer from 'ol/layer/Base';
|
||||
import { BaseMap } from './components/BaseMap';
|
||||
|
||||
// export interface VietNamMapProps {
|
||||
// style?: React.CSSProperties;
|
||||
// onFeatureClick?: (feature: any) => void;
|
||||
// onFeatureSelect?: (feature: any) => void;
|
||||
// onFeaturesClick?: (features: any[]) => void;
|
||||
// onError?: (error: Error) => void;
|
||||
// mapManager?: MapManager;
|
||||
// isMapInitialized?: boolean;
|
||||
// }
|
||||
|
||||
export interface MapManagerConfig {
|
||||
onFeatureClick?: (feature: any) => void;
|
||||
onFeatureSelect?: (feature: any, pixel: any) => void;
|
||||
onFeaturesClick?: (features: any[]) => void;
|
||||
onError?: (error: string[]) => void;
|
||||
}
|
||||
export interface AnimationConfig {
|
||||
duration?: number;
|
||||
maxRadius?: number;
|
||||
strokeColor?: string;
|
||||
strokeWidthBase?: number;
|
||||
}
|
||||
|
||||
export interface StyleConfig {
|
||||
icon?: string; // URL của icon
|
||||
font?: string; // font chữ cho text
|
||||
textColor?: string; // màu chữ
|
||||
textStrokeColor?: string; // màu viền chữ
|
||||
textOffsetY?: number; // độ lệch theo trục Y
|
||||
strokeColor?: string; // màu đường kẻ cho LineString
|
||||
strokeWidth?: number; // độ dày đường kẻ cho LineString
|
||||
fillColor?: string; // màu fill cho Polygon
|
||||
borderColor?: string; // màu viền cho Polygon
|
||||
borderWidth?: number; // độ dày viền cho Polygon
|
||||
minScale?: number; // tỷ lệ nhỏ nhất
|
||||
maxScale?: number; // tỷ lệ lớn nhất
|
||||
}
|
||||
|
||||
// export Interface for feature data
|
||||
export interface FeatureData {
|
||||
id?: string | number;
|
||||
bearing?: number;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface MapLayer {
|
||||
name: string;
|
||||
layer: BaseLayer | null;
|
||||
visible: boolean;
|
||||
mandatory: boolean;
|
||||
}
|
||||
|
||||
export type TagStateCallbackPayload = {
|
||||
isNormal: boolean;
|
||||
isWarning: boolean;
|
||||
isCritical: boolean;
|
||||
isSos: boolean;
|
||||
isDisconnected: boolean; // giữ đúng theo yêu cầu (1 'n')
|
||||
};
|
||||
|
||||
export type PointData = {
|
||||
thing?: SgwModel.SgwThing;
|
||||
description?: string;
|
||||
type: 'main-point' | 'sos-point' | 'default';
|
||||
};
|
||||
export type ZoneData = {
|
||||
zone?: SgwModel.Banzone;
|
||||
message?: string;
|
||||
type: 'warning' | 'alarm' | 'default';
|
||||
};
|
||||
|
||||
export interface GPSParseResult {
|
||||
lat: number;
|
||||
lon: number;
|
||||
s: number;
|
||||
h: number;
|
||||
fishing: boolean;
|
||||
}
|
||||
|
||||
export interface ShipDetailData {
|
||||
ship: SgwModel.ShipDetail;
|
||||
owner: MasterModel.UserResponse | null;
|
||||
gps: GPSParseResult | null;
|
||||
trip_id?: string;
|
||||
zone_entered_alarm_list: ZoneAlarmParse[];
|
||||
zone_approaching_alarm_list: ZoneAlarmParse[];
|
||||
}
|
||||
|
||||
export interface ZoneAlarmParse {
|
||||
zone_type?: number;
|
||||
zone_id?: string;
|
||||
zone_name?: string;
|
||||
message?: string;
|
||||
alarm_type?: number;
|
||||
lat?: number;
|
||||
lon?: number;
|
||||
s?: number;
|
||||
h?: number;
|
||||
fishing?: boolean;
|
||||
gps_time?: number;
|
||||
}
|
||||
export const DATA_LAYER = 'data-layer';
|
||||
export const TEMPORARY_LAYER = 'temporary-layer';
|
||||
|
||||
// Export BaseMap for external use
|
||||
export { BaseMap };
|
||||
38
src/pages/Slave/SGW/Ship/components/ButtonSelectGroup.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Button } from 'antd';
|
||||
import ModalTreeSelectGroup from './ModalTreeSelectGroup';
|
||||
|
||||
interface ButtonSelectGroupProps {
|
||||
visible: boolean;
|
||||
onVisibleChange: (visible: boolean) => void;
|
||||
onSubmitGroup: (groupId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const ButtonSelectGroup: React.FC<ButtonSelectGroupProps> = ({
|
||||
visible,
|
||||
onVisibleChange,
|
||||
onSubmitGroup,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="primary" onClick={() => onVisibleChange(true)}>
|
||||
{intl.formatMessage({
|
||||
id: 'pages.groups.setgroup.text',
|
||||
defaultMessage: 'Set group',
|
||||
})}
|
||||
</Button>
|
||||
|
||||
<ModalTreeSelectGroup
|
||||
visible={visible}
|
||||
onModalVisible={onVisibleChange}
|
||||
onSubmit={async (groupId: string) => {
|
||||
await onSubmitGroup(groupId);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ButtonSelectGroup;
|
||||
103
src/pages/Slave/SGW/Ship/components/EditModal.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { ModalForm, ProFormText } from '@ant-design/pro-form';
|
||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||
|
||||
interface EditModalProps {
|
||||
visible: boolean;
|
||||
values: {
|
||||
name?: string;
|
||||
address?: string;
|
||||
group_id?: string;
|
||||
external_id?: string;
|
||||
type?: string;
|
||||
};
|
||||
onVisibleChange: (visible: boolean) => void;
|
||||
onFinish: (
|
||||
values: Pick<MasterModel.Thing, 'name'> &
|
||||
Pick<MasterModel.ThingReponseMetadata, 'address' | 'external_id'>,
|
||||
) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const EditModal: React.FC<EditModalProps> = ({
|
||||
visible,
|
||||
values,
|
||||
onVisibleChange,
|
||||
onFinish,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
return (
|
||||
<ModalForm
|
||||
open={visible}
|
||||
initialValues={values}
|
||||
title={intl.formatMessage({
|
||||
id: 'pages.things.update.title',
|
||||
defaultMessage: 'Update thing',
|
||||
})}
|
||||
width={480}
|
||||
onVisibleChange={onVisibleChange}
|
||||
onFinish={onFinish}
|
||||
modalProps={{
|
||||
destroyOnHidden: true,
|
||||
}}
|
||||
>
|
||||
<ProFormText
|
||||
name="name"
|
||||
label={intl.formatMessage({
|
||||
id: 'pages.things.name',
|
||||
defaultMessage: 'Name',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: (
|
||||
<FormattedMessage
|
||||
id="pages.things.name.required"
|
||||
defaultMessage="The name is required"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<ProFormText
|
||||
name="external_id"
|
||||
label={intl.formatMessage({
|
||||
id: 'pages.things.external_id',
|
||||
defaultMessage: 'ExternalId',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: (
|
||||
<FormattedMessage
|
||||
id="pages.things.external_id.required"
|
||||
defaultMessage="The externalId is required"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<ProFormText
|
||||
name="address"
|
||||
label={intl.formatMessage({
|
||||
id: 'pages.things.address',
|
||||
defaultMessage: 'Address',
|
||||
})}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: (
|
||||
<FormattedMessage
|
||||
id="pages.things.address.required"
|
||||
defaultMessage="The address is required"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditModal;
|
||||
243
src/pages/Slave/SGW/Ship/components/FormAdd.tsx
Normal file
@@ -0,0 +1,243 @@
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
ModalForm,
|
||||
ProFormDateTimePicker,
|
||||
ProFormDigit,
|
||||
ProFormSelect,
|
||||
ProFormText,
|
||||
} from '@ant-design/pro-form';
|
||||
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
|
||||
import { Button, Col, FormInstance, Row, Table } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import FormShareVms from './FormShareVms';
|
||||
|
||||
dayjs.extend(utc);
|
||||
|
||||
interface ShipFormValues extends SgwModel.ShipCreateParams {
|
||||
targets?: SgwModel.SgwThing[];
|
||||
}
|
||||
|
||||
interface FormAddProps {
|
||||
visible: boolean;
|
||||
onVisibleChange: (visible: boolean) => void;
|
||||
onSubmit: (values: ShipFormValues) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const FormAdd: React.FC<FormAddProps> = ({
|
||||
visible,
|
||||
onVisibleChange,
|
||||
onSubmit,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const formRef = useRef<FormInstance<ShipFormValues>>();
|
||||
const [shareModalVisible, handleShareModalVisible] = useState(false);
|
||||
const [selectedDevices, setSelectedDevices] = useState<SgwModel.SgwThing[]>(
|
||||
[],
|
||||
);
|
||||
const { shipTypes, getShipTypes } = useModel('slave.sgw.useShipTypes');
|
||||
const { homeports } = useModel('slave.sgw.useHomePorts');
|
||||
const { groups, getGroups } = useModel('master.useGroups');
|
||||
useEffect(() => {
|
||||
if (!shipTypes) {
|
||||
getShipTypes();
|
||||
}
|
||||
}, [shipTypes]);
|
||||
useEffect(() => {
|
||||
if (!groups) {
|
||||
getGroups();
|
||||
}
|
||||
}, [groups]);
|
||||
|
||||
console.log('groups', homeports, groups);
|
||||
|
||||
// Lọc homeports theo province_code của groups
|
||||
const groupProvinceCodes = Array.isArray(groups)
|
||||
? groups.map((g: MasterModel.GroupNode) => g.metadata?.code).filter(Boolean)
|
||||
: [];
|
||||
const filteredHomeports = Array.isArray(homeports)
|
||||
? homeports.filter((p: SgwModel.Port) =>
|
||||
groupProvinceCodes.includes(p.province_code),
|
||||
)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<ModalForm
|
||||
formRef={formRef}
|
||||
initialValues={{
|
||||
reg_number: '',
|
||||
ship_type: '',
|
||||
name: '', // Changed from shipname to name
|
||||
targets: [],
|
||||
imo_number: '',
|
||||
mmsi_number: '',
|
||||
ship_length: '',
|
||||
ship_power: '',
|
||||
ship_group_id: '',
|
||||
fishing_license_number: '',
|
||||
fishing_license_expiry_date: null,
|
||||
home_port: '',
|
||||
}}
|
||||
title={intl.formatMessage({
|
||||
id: 'pages.ships.create.title',
|
||||
defaultMessage: 'Tạo tàu mới',
|
||||
})}
|
||||
width="580px"
|
||||
open={visible}
|
||||
onVisibleChange={onVisibleChange}
|
||||
onFinish={async (formValues: ShipFormValues) => {
|
||||
console.log('FormAdd onFinish - formValues:', formValues);
|
||||
console.log('FormAdd onFinish - selectedDevices:', selectedDevices);
|
||||
// Gửi selectedDevices vào targets
|
||||
const rest = formValues;
|
||||
const thing_id = selectedDevices?.[0]?.id;
|
||||
const result = await onSubmit({
|
||||
...rest,
|
||||
thing_id,
|
||||
targets: selectedDevices,
|
||||
});
|
||||
console.log('FormAdd onFinish - result:', result);
|
||||
return result;
|
||||
}}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<ProFormText
|
||||
name="reg_number"
|
||||
label="Số đăng ký"
|
||||
rules={[{ required: true, message: 'Nhập số đăng ký' }]}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<ProFormText
|
||||
name="name"
|
||||
label="Tên tàu"
|
||||
rules={[{ required: true, message: 'Nhập tên tàu' }]}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<ProFormSelect
|
||||
name="ship_type"
|
||||
label="Loại tàu"
|
||||
options={
|
||||
Array.isArray(shipTypes)
|
||||
? shipTypes.map((t) => ({ label: t.name, value: t.id }))
|
||||
: []
|
||||
}
|
||||
rules={[{ required: true, message: 'Chọn loại tàu' }]}
|
||||
showSearch
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<ProFormSelect
|
||||
name="home_port"
|
||||
label="Cảng nhà"
|
||||
options={filteredHomeports.map((p) => ({
|
||||
label: p.name,
|
||||
value: p.id,
|
||||
}))}
|
||||
rules={[{ required: true, message: 'Chọn cảng nhà' }]}
|
||||
showSearch
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<ProFormText
|
||||
name="fishing_license_number"
|
||||
label="Số giấy phép"
|
||||
rules={[{ required: true, message: 'Nhập số giấy phép' }]}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<ProFormDateTimePicker
|
||||
name="fishing_license_expiry_date"
|
||||
label={intl.formatMessage({
|
||||
id: 'pages.things.fishing_license_expiry_date',
|
||||
defaultMessage: 'fishing_license_expiry_date',
|
||||
})}
|
||||
rules={[{ required: false }]}
|
||||
transform={(value: string) => {
|
||||
if (!value) return {};
|
||||
return {
|
||||
fishing_license_expiry_date: dayjs(value)
|
||||
.endOf('day')
|
||||
.utc()
|
||||
.format(), // ISO 8601 format: YYYY-MM-DDTHH:mm:ssZ
|
||||
};
|
||||
}}
|
||||
fieldProps={{
|
||||
format: 'YYYY-MM-DD',
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<ProFormDigit
|
||||
name="ship_length"
|
||||
label="Chiều dài (m)"
|
||||
min={0}
|
||||
rules={[{ required: true, message: 'Nhập chiều dài tàu' }]}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<ProFormDigit
|
||||
name="ship_power"
|
||||
label="Công suất (CV)"
|
||||
min={0}
|
||||
rules={[{ required: true, message: 'Nhập công suất tàu' }]}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
{/* <Col span={12}>
|
||||
<GroupShipSelect />
|
||||
</Col> */}
|
||||
|
||||
<Col span={24}>
|
||||
<Button
|
||||
type="primary"
|
||||
key="primary"
|
||||
onClick={() => {
|
||||
handleShareModalVisible(true);
|
||||
}}
|
||||
>
|
||||
<PlusOutlined />{' '}
|
||||
<FormattedMessage
|
||||
id="pages.things.share.text"
|
||||
defaultMessage="Share"
|
||||
/>
|
||||
</Button>
|
||||
{/* Hiển thị bảng thiết bị đã chọn */}
|
||||
{selectedDevices.length > 0 && (
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={selectedDevices.map((item, idx) => ({
|
||||
key: idx,
|
||||
device: item.name || item,
|
||||
}))}
|
||||
columns={[
|
||||
{ title: 'Thiết bị', dataIndex: 'device', key: 'device' },
|
||||
]}
|
||||
scroll={{ y: 240 }}
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
)}
|
||||
<FormShareVms
|
||||
visible={shareModalVisible}
|
||||
onVisibleChange={handleShareModalVisible}
|
||||
groups={groups || []}
|
||||
onSubmit={async (values: { things: SgwModel.SgwThing[] }) => {
|
||||
setSelectedDevices(values.things || []);
|
||||
handleShareModalVisible(false);
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormAdd;
|
||||
250
src/pages/Slave/SGW/Ship/components/FormShareVms.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import TreeGroup from '@/components/shared/TreeGroup';
|
||||
import { PADDING_BLOCK, PADDING_IN_LINE } from '@/constants';
|
||||
import { apiSearchThings } from '@/services/master/ThingController';
|
||||
import ProCard from '@ant-design/pro-card';
|
||||
import type { ProFormInstance } from '@ant-design/pro-form';
|
||||
import { ModalForm } from '@ant-design/pro-form';
|
||||
import type { ActionType, ProColumns } from '@ant-design/pro-table';
|
||||
import ProTable from '@ant-design/pro-table';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Input } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
type Group = {
|
||||
id: string;
|
||||
metadata?: Record<string, unknown> & {
|
||||
code?: string;
|
||||
province_code?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export default ({
|
||||
visible,
|
||||
onVisibleChange,
|
||||
onSubmit,
|
||||
groups,
|
||||
}: {
|
||||
visible: boolean;
|
||||
onVisibleChange: (visible: boolean) => void;
|
||||
onSubmit: (
|
||||
values: { things: SgwModel.SgwThing[] } & Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
groups: Group[];
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const actionRef = useRef<ActionType>();
|
||||
const formRef = useRef<ProFormInstance<Record<string, unknown>>>();
|
||||
const [selectedRowsState, setSelectedRows] = useState<SgwModel.SgwThing[]>(
|
||||
[],
|
||||
);
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
const [groupCheckedKeys, setGroupCheckedKeys] = useState<string[]>([]);
|
||||
const [responsive] = useState(false);
|
||||
|
||||
// Lấy danh sách group IDs từ groups truyền vào
|
||||
const accountManagedGroups = Array.isArray(groups)
|
||||
? groups.map((g: Group) => g.id)
|
||||
: [];
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setSearchValue('');
|
||||
setSelectedRows([]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const columns: ProColumns<SgwModel.SgwThing>[] = [
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: 'pages.things.name',
|
||||
defaultMessage: 'Name',
|
||||
}),
|
||||
dataIndex: 'name',
|
||||
render: (dom: React.ReactNode, record: SgwModel.SgwThing) => {
|
||||
const text = record?.name;
|
||||
const isDisabled = !!record?.metadata?.ship_id;
|
||||
return (
|
||||
<span style={{ color: isDisabled ? '#aaa' : 'inherit' }}>{text}</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ModalForm
|
||||
formRef={formRef}
|
||||
initialValues={{ things: [] }}
|
||||
title={intl.formatMessage({
|
||||
id: 'pages.users.share.title',
|
||||
defaultMessage: 'Share thing',
|
||||
})}
|
||||
width="720px"
|
||||
visible={visible}
|
||||
onVisibleChange={onVisibleChange}
|
||||
onFinish={async (values) => {
|
||||
// Validate things selected
|
||||
if (!selectedRowsState || selectedRowsState.length === 0) {
|
||||
formRef.current?.setFields([
|
||||
{
|
||||
name: 'things',
|
||||
errors: [
|
||||
intl.formatMessage({
|
||||
id: 'pages.users.things.required',
|
||||
defaultMessage: 'Please select things',
|
||||
}),
|
||||
],
|
||||
},
|
||||
]);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Add selected things to values
|
||||
const submitValues = {
|
||||
...values,
|
||||
things: selectedRowsState,
|
||||
};
|
||||
|
||||
await onSubmit(submitValues);
|
||||
}}
|
||||
>
|
||||
{/* Danh sách thiết bị */}
|
||||
<div>
|
||||
<label style={{ marginBottom: 8, display: 'block', fontWeight: 500 }}>
|
||||
{intl.formatMessage({
|
||||
id: 'pages.users.things.list',
|
||||
defaultMessage: 'List things',
|
||||
})}
|
||||
<span style={{ color: '#ff4d4f', marginLeft: 4 }}>*</span>
|
||||
</label>
|
||||
<ProCard split="vertical" bordered>
|
||||
<ProCard
|
||||
colSpan="240px"
|
||||
bodyStyle={
|
||||
responsive
|
||||
? {
|
||||
paddingInline: PADDING_IN_LINE,
|
||||
paddingBlock: PADDING_BLOCK,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<TreeGroup
|
||||
multiple={true}
|
||||
groupIds={groupCheckedKeys}
|
||||
allowedGroupIds={accountManagedGroups}
|
||||
onSelected={(value) => {
|
||||
setGroupCheckedKeys(
|
||||
value ? (Array.isArray(value) ? value : [value]) : [],
|
||||
);
|
||||
if (actionRef.current) {
|
||||
actionRef.current.reload();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ProCard>
|
||||
<ProCard
|
||||
bodyStyle={
|
||||
responsive
|
||||
? {
|
||||
paddingInline: PADDING_IN_LINE,
|
||||
paddingBlock: PADDING_BLOCK,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<Input.Search
|
||||
allowClear
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'pages.things.name',
|
||||
defaultMessage: 'Search by name',
|
||||
})}
|
||||
style={{ width: 240 }}
|
||||
onSearch={(value) => {
|
||||
setSearchValue(value);
|
||||
actionRef.current?.reload();
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) {
|
||||
setSearchValue('');
|
||||
actionRef.current?.reload();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ProTable
|
||||
actionRef={actionRef}
|
||||
columns={columns}
|
||||
tableLayout="auto"
|
||||
rowKey="id"
|
||||
dateFormatter="string"
|
||||
pagination={{
|
||||
showQuickJumper: false,
|
||||
showSizeChanger: false,
|
||||
pageSize: 5,
|
||||
}}
|
||||
request={async (
|
||||
params: { current?: number; pageSize?: number } = {},
|
||||
) => {
|
||||
const { current = 1, pageSize } = params;
|
||||
const size = pageSize || 5;
|
||||
const offset = current === 1 ? 0 : (current - 1) * size;
|
||||
|
||||
// Build search body
|
||||
const searchBody: MasterModel.SearchThingPaginationBody = {
|
||||
offset: offset,
|
||||
limit: size,
|
||||
order: 'name',
|
||||
dir: 'asc',
|
||||
};
|
||||
|
||||
// Add group filter if selected
|
||||
if (groupCheckedKeys.length > 0 && groupCheckedKeys[0]) {
|
||||
searchBody.metadata = {
|
||||
group_id: groupCheckedKeys[0], // Use first selected group
|
||||
};
|
||||
}
|
||||
|
||||
// Add name filter if search value exists
|
||||
if (searchValue) {
|
||||
searchBody.name = searchValue;
|
||||
}
|
||||
|
||||
// Call API
|
||||
const response = await apiSearchThings(searchBody, 'sgw');
|
||||
|
||||
return {
|
||||
data: response.things || [],
|
||||
success: true,
|
||||
total: response.total || 0,
|
||||
};
|
||||
}}
|
||||
search={false}
|
||||
rowSelection={{
|
||||
type: 'radio',
|
||||
selectedRowKeys: selectedRowsState
|
||||
.map((row) => row.id)
|
||||
.filter(Boolean) as React.Key[],
|
||||
onChange: (_: unknown, selectedRows: SgwModel.SgwThing[]) => {
|
||||
const first = selectedRows?.[0] ? [selectedRows[0]] : [];
|
||||
setSelectedRows(first as SgwModel.SgwThing[]);
|
||||
formRef.current?.setFieldsValue({ things: first });
|
||||
},
|
||||
getCheckboxProps: (record: SgwModel.SgwThing) => ({
|
||||
// Disable if already linked to a ship (ship_id present)
|
||||
disabled: !!record?.metadata?.ship_id,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</ProCard>
|
||||
</ProCard>
|
||||
</div>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
72
src/pages/Slave/SGW/Ship/components/ModalTreeSelectGroup.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
|
||||
import { ModalForm, ProFormItem } from '@ant-design/pro-form';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Alert, Form } from 'antd';
|
||||
|
||||
interface ModalTreeSelectGroupProps {
|
||||
visible: boolean;
|
||||
onModalVisible: (visible: boolean) => void;
|
||||
onSubmit: (groupId: string) => Promise<boolean | void>;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
group_id?: string;
|
||||
}
|
||||
|
||||
const ModalTreeSelectGroup: React.FC<ModalTreeSelectGroupProps> = ({
|
||||
visible,
|
||||
onSubmit,
|
||||
onModalVisible,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
|
||||
return (
|
||||
<ModalForm<FormValues>
|
||||
form={form}
|
||||
title={intl.formatMessage({
|
||||
id: 'pages.groups.setgroup.title',
|
||||
defaultMessage: 'Set group',
|
||||
})}
|
||||
width="480px"
|
||||
open={visible} // ⚠️ dùng open, không dùng visible (antd mới)
|
||||
onOpenChange={onModalVisible} // ⚠️ thay cho onVisibleChange
|
||||
onFinish={async (values) => {
|
||||
const groupId = values.group_id;
|
||||
|
||||
if (!groupId) {
|
||||
return false; // không cho submit nếu chưa chọn
|
||||
}
|
||||
|
||||
await onSubmit(groupId);
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<Alert
|
||||
type="warning"
|
||||
message={intl.formatMessage({
|
||||
id: 'pages.groups.select.alert',
|
||||
defaultMessage: 'The thing is only added to the leaf group',
|
||||
})}
|
||||
/>
|
||||
|
||||
<br />
|
||||
|
||||
<ProFormItem
|
||||
name="group_id"
|
||||
rules={[{ required: true, message: 'Please select a group' }]}
|
||||
>
|
||||
<TreeSelectedGroup
|
||||
groupIds={form.getFieldValue('group_id')}
|
||||
onSelected={(value) => {
|
||||
form.setFieldsValue({
|
||||
group_id: value as string,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ProFormItem>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModalTreeSelectGroup;
|
||||
490
src/pages/Slave/SGW/Ship/index.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
import { DeleteButton, EditButton } from '@/components/shared/Button';
|
||||
import { DEFAULT_PAGE_SIZE } from '@/constants';
|
||||
import {
|
||||
apiAddShip,
|
||||
apiDeleteShip,
|
||||
apiQueryShips,
|
||||
apiUpdateShip,
|
||||
} from '@/services/slave/sgw/ShipController';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import ProCard from '@ant-design/pro-card';
|
||||
import ProDescriptions from '@ant-design/pro-descriptions';
|
||||
import { FooterToolbar } from '@ant-design/pro-layout';
|
||||
import ProTable, { ActionType, ProColumns } from '@ant-design/pro-table';
|
||||
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
|
||||
import { Button, Drawer, message, Typography } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import EditModal from './components/EditModal';
|
||||
import FormAdd from './components/FormAdd';
|
||||
|
||||
const { Paragraph } = Typography;
|
||||
|
||||
type ShipFormValues = Partial<SgwModel.ShipDetail>;
|
||||
|
||||
type ShipFormAdd = Partial<SgwModel.ShipCreateParams>;
|
||||
|
||||
const ManagerShips: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const { initialState } = useModel('@@initialState');
|
||||
const { currentUserProfile } = initialState || {};
|
||||
console.log('Current User Profile:', currentUserProfile);
|
||||
const [responsive] = useState<boolean>(false);
|
||||
const [updateModalVisible, handleUpdateModalVisible] =
|
||||
useState<boolean>(false);
|
||||
const [createModalVisible, handleCreateModalVisible] =
|
||||
useState<boolean>(false);
|
||||
const [showDetail, setShowDetail] = useState<boolean>(false);
|
||||
const [currentRow, setCurrentRow] = useState<SgwModel.ShipDetail | null>(
|
||||
null,
|
||||
);
|
||||
const actionRef = useRef<ActionType | null>(null);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [selectedRowsState, setSelectedRowsState] = useState<
|
||||
SgwModel.ShipDetail[]
|
||||
>([]);
|
||||
const { shipTypes, getShipTypes } = useModel('slave.sgw.useShipTypes');
|
||||
const { homeports, getHomeportsByProvinceCode } = useModel(
|
||||
'slave.sgw.useHomePorts',
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!shipTypes) {
|
||||
getShipTypes();
|
||||
}
|
||||
}, [shipTypes]);
|
||||
useEffect(() => {
|
||||
getHomeportsByProvinceCode();
|
||||
}, [getHomeportsByProvinceCode]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setSelectedRowsState([]);
|
||||
setCurrentRow(null);
|
||||
actionRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleAdd = async (fields: ShipFormAdd) => {
|
||||
const key = 'add_ship';
|
||||
const {
|
||||
reg_number,
|
||||
name, // Changed from shipname to name
|
||||
thing_id,
|
||||
ship_type,
|
||||
ship_length,
|
||||
ship_power,
|
||||
ship_group_id,
|
||||
home_port,
|
||||
fishing_license_number,
|
||||
fishing_license_expiry_date,
|
||||
} = fields;
|
||||
console.log('Fields received from form:', fields);
|
||||
|
||||
if (!thing_id || thing_id.length === 0) {
|
||||
message.error('Vui lòng chọn một thiết bị để liên kết.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const newShip = {
|
||||
name: name, // Use name directly
|
||||
reg_number: reg_number,
|
||||
thing_id: thing_id,
|
||||
ship_type: ship_type,
|
||||
home_port: home_port,
|
||||
fishing_license_number: fishing_license_number,
|
||||
fishing_license_expiry_date: fishing_license_expiry_date,
|
||||
ship_length: Number(ship_length),
|
||||
ship_power: Number(ship_power),
|
||||
ship_group_id: ship_group_id,
|
||||
};
|
||||
|
||||
try {
|
||||
messageApi.open({
|
||||
type: 'loading',
|
||||
content: intl.formatMessage({
|
||||
id: 'pages.things.creating',
|
||||
defaultMessage: 'creating...',
|
||||
}),
|
||||
duration: 0,
|
||||
key,
|
||||
});
|
||||
|
||||
const id = await apiAddShip({ ...newShip });
|
||||
if (id) {
|
||||
messageApi.open({
|
||||
type: 'success',
|
||||
content: intl.formatMessage({
|
||||
id: 'pages.things.add.success',
|
||||
defaultMessage: 'Added successfully and will refresh soon',
|
||||
}),
|
||||
key,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
messageApi.open({
|
||||
type: 'error',
|
||||
content: intl.formatMessage({
|
||||
id: 'pages.things.add.failed',
|
||||
defaultMessage: 'Adding failed, please try again!',
|
||||
}),
|
||||
key,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = async (
|
||||
selectedRows: SgwModel.ShipDetail[],
|
||||
): Promise<boolean> => {
|
||||
const key = 'remove_ship';
|
||||
if (!selectedRows) return true;
|
||||
try {
|
||||
messageApi.open({
|
||||
type: 'loading',
|
||||
content: intl.formatMessage({
|
||||
id: 'pages.ships.deleting',
|
||||
defaultMessage: 'Deleting...',
|
||||
}),
|
||||
duration: 0,
|
||||
key,
|
||||
});
|
||||
const allDelete = selectedRows.map(async (row) => {
|
||||
if (row.id) await apiDeleteShip(row.id);
|
||||
});
|
||||
await Promise.all(allDelete);
|
||||
messageApi.open({
|
||||
type: 'success',
|
||||
content: intl.formatMessage({
|
||||
id: 'pages.ships.delete.success',
|
||||
defaultMessage: 'Deleted successfully and will refresh soon',
|
||||
}),
|
||||
key,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
messageApi.open({
|
||||
type: 'error',
|
||||
content: intl.formatMessage({
|
||||
id: 'pages.ships.delete.failed',
|
||||
defaultMessage: 'Delete failed, please try again!',
|
||||
}),
|
||||
key,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async (values: ShipFormValues): Promise<boolean> => {
|
||||
const key = 'update_ship';
|
||||
try {
|
||||
messageApi.open({
|
||||
type: 'loading',
|
||||
content: intl.formatMessage({
|
||||
id: 'pages.ships.updating',
|
||||
defaultMessage: 'Updating...',
|
||||
}),
|
||||
duration: 0,
|
||||
key,
|
||||
});
|
||||
// Fix: loại bỏ ship_group_id nếu là null
|
||||
const patch = { ...values };
|
||||
if (patch.ship_group_id === null) delete patch.ship_group_id;
|
||||
if (!currentRow?.id) throw new Error('Missing ship id');
|
||||
await apiUpdateShip(currentRow.id, patch as SgwModel.ShipUpdateParams);
|
||||
messageApi.open({
|
||||
type: 'success',
|
||||
content: intl.formatMessage({
|
||||
id: 'pages.ships.update.success',
|
||||
defaultMessage: 'Updated successfully and will refresh soon',
|
||||
}),
|
||||
key,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
messageApi.open({
|
||||
type: 'error',
|
||||
content: intl.formatMessage({
|
||||
id: 'pages.ships.update.failed',
|
||||
defaultMessage: 'Update failed, please try again!',
|
||||
}),
|
||||
key,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ProColumns<SgwModel.ShipDetail, 'text'>[] = [
|
||||
{
|
||||
key: 'reg_number',
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id="pages.ships.reg_number"
|
||||
defaultMessage="Registration Number"
|
||||
/>
|
||||
),
|
||||
dataIndex: 'reg_number',
|
||||
render: (_, record) => (
|
||||
<a
|
||||
style={{ color: '#1890ff', cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
setCurrentRow(record);
|
||||
setShowDetail(true);
|
||||
}}
|
||||
>
|
||||
{record?.reg_number}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
title: (
|
||||
<FormattedMessage id="pages.ships.name" defaultMessage="Ship Name" />
|
||||
),
|
||||
dataIndex: 'name',
|
||||
render: (dom, record) => (
|
||||
<Paragraph
|
||||
style={{
|
||||
marginBottom: 0,
|
||||
verticalAlign: 'middle',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
copyable
|
||||
>
|
||||
{record?.name}
|
||||
</Paragraph>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ship_type',
|
||||
title: <FormattedMessage id="pages.ships.type" defaultMessage="Type" />,
|
||||
dataIndex: 'ship_type',
|
||||
render: (dom, record) => {
|
||||
const typeObj = Array.isArray(shipTypes)
|
||||
? shipTypes.find((t) => t.id === record?.ship_type)
|
||||
: undefined;
|
||||
return typeObj?.name || record?.ship_type || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'home_port',
|
||||
title: (
|
||||
<FormattedMessage
|
||||
id="pages.ships.home_port"
|
||||
defaultMessage="Home Port"
|
||||
/>
|
||||
),
|
||||
dataIndex: 'home_port',
|
||||
hideInSearch: true,
|
||||
render: (dom, record) => {
|
||||
const portObj = Array.isArray(homeports)
|
||||
? homeports.find((p) => p.id === record?.home_port)
|
||||
: undefined;
|
||||
return portObj?.name || record?.home_port || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: (
|
||||
<FormattedMessage id="pages.ships.option" defaultMessage="Operating" />
|
||||
),
|
||||
hideInSearch: true,
|
||||
render: (dom, record) => (
|
||||
<EditButton
|
||||
text={intl.formatMessage({
|
||||
id: 'pages.ships.edit.text',
|
||||
defaultMessage: 'Edit',
|
||||
})}
|
||||
onClick={() => {
|
||||
setCurrentRow(record);
|
||||
handleUpdateModalVisible(true);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Định nghĩa tạm detailColumns cho ProDescriptions
|
||||
const detailColumns = [
|
||||
{ title: 'Tên tàu', dataIndex: 'name' },
|
||||
{ title: 'Chiều dài tàu', dataIndex: 'ship_length' },
|
||||
{ title: 'Công suất tàu', dataIndex: 'ship_power' },
|
||||
{ title: 'Đội tàu', dataIndex: 'group_ship' },
|
||||
{ title: 'Ảnh tàu', dataIndex: 'reg_number' },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<ProCard
|
||||
split={responsive ? 'horizontal' : 'vertical'}
|
||||
style={{ minHeight: 560 }}
|
||||
>
|
||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 24, lg: 24, xl: 24 }}>
|
||||
<ProTable
|
||||
actionRef={actionRef}
|
||||
columns={columns}
|
||||
tableLayout="auto"
|
||||
rowKey="id"
|
||||
search={{ layout: 'vertical', defaultCollapsed: false }}
|
||||
dateFormatter="string"
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedRowsState
|
||||
.map((row) => row.id!)
|
||||
.filter(Boolean),
|
||||
onChange: (
|
||||
_: React.Key[],
|
||||
selectedRows: SgwModel.ShipDetail[],
|
||||
) => {
|
||||
setSelectedRowsState(selectedRows);
|
||||
},
|
||||
}}
|
||||
pagination={{ pageSize: DEFAULT_PAGE_SIZE * 2 }}
|
||||
request={async (params = {}) => {
|
||||
const {
|
||||
current = 1,
|
||||
pageSize,
|
||||
name,
|
||||
registration_number,
|
||||
} = params as {
|
||||
current?: number;
|
||||
pageSize?: number;
|
||||
name?: string;
|
||||
registration_number?: string;
|
||||
};
|
||||
const size = pageSize || DEFAULT_PAGE_SIZE * 2;
|
||||
const offset = current === 1 ? 0 : (current - 1) * size;
|
||||
const query: SgwModel.ShipQueryParams = {
|
||||
offset: offset,
|
||||
limit: size,
|
||||
order: 'name',
|
||||
dir: 'asc',
|
||||
name,
|
||||
registration_number,
|
||||
};
|
||||
const resp = await apiQueryShips(query);
|
||||
return {
|
||||
data: resp.ships || [],
|
||||
success: true,
|
||||
total: resp.total || 0,
|
||||
};
|
||||
}}
|
||||
toolBarRender={() => {
|
||||
return [
|
||||
<Button
|
||||
type="primary"
|
||||
key="primary"
|
||||
onClick={() => handleCreateModalVisible(true)}
|
||||
>
|
||||
<PlusOutlined />{' '}
|
||||
<FormattedMessage
|
||||
id="pages.ship.create.text"
|
||||
defaultMessage="New"
|
||||
/>
|
||||
</Button>,
|
||||
];
|
||||
}}
|
||||
/>
|
||||
</ProCard>
|
||||
</ProCard>
|
||||
|
||||
<FormAdd
|
||||
visible={createModalVisible}
|
||||
onVisibleChange={handleCreateModalVisible}
|
||||
onSubmit={async (values: ShipFormAdd) => {
|
||||
console.log('index.tsx onSubmit called with values:', values);
|
||||
const success = await handleAdd(values);
|
||||
console.log('index.tsx onSubmit - success:', success);
|
||||
if (success) {
|
||||
handleCreateModalVisible(false);
|
||||
if (actionRef.current) {
|
||||
actionRef.current.reload();
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}}
|
||||
/>
|
||||
|
||||
{currentRow && (
|
||||
<EditModal
|
||||
visible={updateModalVisible}
|
||||
values={currentRow}
|
||||
onVisibleChange={(visible) => {
|
||||
handleUpdateModalVisible(visible);
|
||||
if (!visible) setCurrentRow(null);
|
||||
}}
|
||||
onFinish={async (values: ShipFormValues) => {
|
||||
const success = await handleUpdate(values);
|
||||
if (success) {
|
||||
handleUpdateModalVisible(false);
|
||||
setCurrentRow(null);
|
||||
actionRef.current?.reload();
|
||||
}
|
||||
return success;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selectedRowsState?.length > 0 && (
|
||||
<FooterToolbar
|
||||
extra={
|
||||
<div>
|
||||
<FormattedMessage
|
||||
id="pages.ships.chosen"
|
||||
defaultMessage="Chosen"
|
||||
/>{' '}
|
||||
<a style={{ fontWeight: 600 }}>{selectedRowsState.length}</a>{' '}
|
||||
<FormattedMessage id="pages.ships.item" defaultMessage="item" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DeleteButton
|
||||
title={intl.formatMessage({
|
||||
id: 'pages.ships.deletion.title',
|
||||
defaultMessage: 'Are you sure to delete these selected ships?',
|
||||
})}
|
||||
text={intl.formatMessage({
|
||||
id: 'pages.ships.deletion.text',
|
||||
defaultMessage: 'Batch deletion',
|
||||
})}
|
||||
onOk={async () => {
|
||||
const success = await handleRemove(selectedRowsState);
|
||||
if (success) {
|
||||
setSelectedRowsState([]);
|
||||
if (actionRef.current) {
|
||||
actionRef.current.reload();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</FooterToolbar>
|
||||
)}
|
||||
<Drawer
|
||||
width={600}
|
||||
visible={showDetail}
|
||||
title={
|
||||
<FormattedMessage
|
||||
id="pages.ships.detail"
|
||||
defaultMessage="Ship Detail"
|
||||
/>
|
||||
}
|
||||
onClose={() => {
|
||||
setShowDetail(false);
|
||||
setCurrentRow(null);
|
||||
}}
|
||||
>
|
||||
<ProDescriptions<SgwModel.ShipDetail>
|
||||
column={1}
|
||||
bordered
|
||||
request={async () => ({
|
||||
data: currentRow ? [currentRow] : [],
|
||||
success: true,
|
||||
})}
|
||||
params={{ id: currentRow?.id }}
|
||||
columns={detailColumns}
|
||||
/>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManagerShips;
|
||||
48
src/pages/Slave/SGW/Trip/components/BadgeTripStatus.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useIntl } from '@umijs/max';
|
||||
import type { BadgeProps } from 'antd';
|
||||
import { Badge } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
interface BadgeTripStatusProps {
|
||||
status?: number;
|
||||
}
|
||||
|
||||
const BadgeTripStatus: React.FC<BadgeTripStatusProps> = ({ status }) => {
|
||||
const intl = useIntl();
|
||||
// Khai báo kiểu cho map
|
||||
const statusBadgeMap: Record<number, BadgeProps> = {
|
||||
0: { status: 'default' }, // Đã khởi tạo
|
||||
1: { status: 'processing' }, // Chờ duyệt
|
||||
2: { status: 'success' }, // Đã duyệt
|
||||
3: { status: 'success' }, // Đang hoạt động
|
||||
4: { status: 'success' }, // Hoàn thành
|
||||
5: { status: 'error' }, // Đã huỷ
|
||||
};
|
||||
|
||||
const getBadgeProps = (status: number | undefined) => {
|
||||
switch (status) {
|
||||
case 0:
|
||||
return intl.formatMessage({ id: 'trip.badge.notApproved' });
|
||||
case 1:
|
||||
return intl.formatMessage({ id: 'trip.badge.waitingApproval' });
|
||||
case 2:
|
||||
return intl.formatMessage({ id: 'trip.badge.approved' });
|
||||
case 3:
|
||||
return intl.formatMessage({ id: 'trip.badge.active' });
|
||||
case 4:
|
||||
return intl.formatMessage({ id: 'trip.badge.completed' });
|
||||
case 5:
|
||||
return intl.formatMessage({ id: 'trip.badge.cancelled' });
|
||||
default:
|
||||
return intl.formatMessage({ id: 'trip.badge.unknown' });
|
||||
}
|
||||
};
|
||||
|
||||
const badgeProps: BadgeProps = statusBadgeMap[status ?? -1] || {
|
||||
status: 'default',
|
||||
};
|
||||
|
||||
return <Badge {...badgeProps} text={getBadgeProps(status)} />;
|
||||
};
|
||||
|
||||
export default BadgeTripStatus;
|
||||
43
src/pages/Slave/SGW/Trip/components/CancelTrip.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { ModalForm, ProFormTextArea } from '@ant-design/pro-components';
|
||||
import { useIntl } from '@umijs/max';
|
||||
import { Button, Form } from 'antd';
|
||||
interface CancelTripProps {
|
||||
onFinished?: (note: string) => void;
|
||||
}
|
||||
const CancelTrip: React.FC<CancelTripProps> = ({ onFinished }) => {
|
||||
const intl = useIntl();
|
||||
const [form] = Form.useForm<{ note: string }>();
|
||||
return (
|
||||
<ModalForm
|
||||
title={intl.formatMessage({ id: 'trip.cancelTrip.title' })}
|
||||
form={form}
|
||||
modalProps={{
|
||||
destroyOnHidden: true,
|
||||
onCancel: () => console.log('run'),
|
||||
}}
|
||||
trigger={
|
||||
<Button color="danger" variant="solid">
|
||||
{intl.formatMessage({ id: 'trip.cancelTrip.button' })}
|
||||
</Button>
|
||||
}
|
||||
onFinish={async (values) => {
|
||||
onFinished?.(values.note);
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ProFormTextArea
|
||||
name="note"
|
||||
label={intl.formatMessage({ id: 'trip.cancelTrip.reason' })}
|
||||
placeholder={intl.formatMessage({ id: 'trip.cancelTrip.placeholder' })}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: intl.formatMessage({ id: 'trip.cancelTrip.validation' }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ModalForm>
|
||||
);
|
||||
};
|
||||
|
||||
export default CancelTrip;
|
||||