Compare commits
2 Commits
e5b388505a
...
b0b09a86b7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0b09a86b7 | ||
|
|
1a06328c77 |
@@ -23,6 +23,13 @@ export default defineConfig({
|
|||||||
path: '/map',
|
path: '/map',
|
||||||
component: './Slave/SGW/Map',
|
component: './Slave/SGW/Map',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'sgw.ships',
|
||||||
|
icon: 'icon-ship',
|
||||||
|
path: '/ships',
|
||||||
|
component: './Slave/SGW/Ship',
|
||||||
|
access: 'canEndUser_User',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'sgw.trips',
|
name: 'sgw.trips',
|
||||||
icon: 'icon-trip',
|
icon: 'icon-trip',
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ import {
|
|||||||
} from './utils/storage';
|
} from './utils/storage';
|
||||||
const isProdBuild = process.env.NODE_ENV === 'production';
|
const isProdBuild = process.env.NODE_ENV === 'production';
|
||||||
export type InitialStateResponse = {
|
export type InitialStateResponse = {
|
||||||
getUserProfile?: () => Promise<MasterModel.ProfileResponse | undefined>;
|
getUserProfile?: () => Promise<MasterModel.UserResponse | undefined>;
|
||||||
currentUserProfile?: MasterModel.ProfileResponse;
|
currentUserProfile?: MasterModel.UserResponse;
|
||||||
theme?: 'light' | 'dark';
|
theme?: 'light' | 'dark';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
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 = ({
|
export const AvatarDropdown = ({
|
||||||
currentUserProfile,
|
currentUserProfile,
|
||||||
}: {
|
}: {
|
||||||
currentUserProfile?: MasterModel.ProfileResponse;
|
currentUserProfile?: MasterModel.UserResponse;
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
return (
|
return (
|
||||||
|
|||||||
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
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;
|
||||||
|
}
|
||||||
@@ -5,6 +5,9 @@ export const DATE_TIME_FORMAT = 'DD/MM/YYYY HH:mm:ss';
|
|||||||
export const TIME_FORMAT = 'HH:mm:ss';
|
export const TIME_FORMAT = 'HH:mm:ss';
|
||||||
export const DATE_FORMAT = 'DD/MM/YYYY';
|
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_NORMAL = 0;
|
||||||
export const STATUS_WARNING = 1;
|
export const STATUS_WARNING = 1;
|
||||||
export const STATUS_DANGEROUS = 2;
|
export const STATUS_DANGEROUS = 2;
|
||||||
|
|||||||
@@ -4,3 +4,12 @@ export enum SGW_ROLE {
|
|||||||
USERS = 'users',
|
USERS = 'users',
|
||||||
ENDUSER = 'enduser',
|
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,31 @@ export const SGW_ROUTE_HOME = '/maps';
|
|||||||
export const SGW_ROUTE_TRIP = '/trip';
|
export const SGW_ROUTE_TRIP = '/trip';
|
||||||
export const SGW_ROUTE_CREATE_BANZONE = '/manager/banzones/create';
|
export const SGW_ROUTE_CREATE_BANZONE = '/manager/banzones/create';
|
||||||
export const SGW_ROUTE_MANAGER_BANZONE = '/manager/banzones';
|
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/fish-species';
|
||||||
|
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';
|
||||||
|
|
||||||
|
// Banzone API Routes
|
||||||
|
export const SGW_ROUTE_BANZONES = '/api/sgw/banzones';
|
||||||
|
export const SGW_ROUTE_BANZONES_LIST = '/api/sgw/banzones/list';
|
||||||
|
|||||||
1
src/constants/slave/sgw/websocket.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const SHIP_SOS_WS_URL = 'wss://sgw.gms.vn/thingscache';
|
||||||
@@ -38,6 +38,7 @@ export default {
|
|||||||
'common.type': 'Type',
|
'common.type': 'Type',
|
||||||
'common.type.placeholder': 'Select Type',
|
'common.type.placeholder': 'Select Type',
|
||||||
'common.status': 'Status',
|
'common.status': 'Status',
|
||||||
|
'common.connect': 'Connection',
|
||||||
'common.province': 'Province',
|
'common.province': 'Province',
|
||||||
'common.description': 'Description',
|
'common.description': 'Description',
|
||||||
'common.description.required': 'Description is required',
|
'common.description.required': 'Description is required',
|
||||||
@@ -48,6 +49,7 @@ export default {
|
|||||||
'common.updated_at': 'Updated At',
|
'common.updated_at': 'Updated At',
|
||||||
'common.undefined': 'Undefined',
|
'common.undefined': 'Undefined',
|
||||||
'common.not_empty': 'Cannot be empty!',
|
'common.not_empty': 'Cannot be empty!',
|
||||||
|
'common.level.disconnected': 'Disconnected',
|
||||||
'common.level.normal': 'Normal',
|
'common.level.normal': 'Normal',
|
||||||
'common.level.warning': 'Warning',
|
'common.level.warning': 'Warning',
|
||||||
'common.level.critical': 'Critical',
|
'common.level.critical': 'Critical',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export default {
|
export default {
|
||||||
'menu.sgw.map': 'Maps',
|
'menu.sgw.map': 'Maps',
|
||||||
'menu.sgw.trips': 'Trips',
|
'menu.sgw.trips': 'Trips',
|
||||||
|
'menu.sgw.ships': 'Ship',
|
||||||
'menu.manager.sgw.fishes': 'Fishes',
|
'menu.manager.sgw.fishes': 'Fishes',
|
||||||
'menu.manager.sgw.zones': 'Zones',
|
'menu.manager.sgw.zones': 'Zones',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export default {
|
|||||||
'common.type': 'Loại',
|
'common.type': 'Loại',
|
||||||
'common.type.placeholder': 'Chọn loại',
|
'common.type.placeholder': 'Chọn loại',
|
||||||
'common.status': 'Trạng thái',
|
'common.status': 'Trạng thái',
|
||||||
|
'common.connect': 'Kết nối',
|
||||||
'common.province': 'Tỉnh',
|
'common.province': 'Tỉnh',
|
||||||
'common.description': 'Mô tả',
|
'common.description': 'Mô tả',
|
||||||
'common.description.required': 'Mô tả không được để trống',
|
'common.description.required': 'Mô tả không được để trống',
|
||||||
@@ -49,6 +50,7 @@ export default {
|
|||||||
'common.updated_at': 'Ngày cập nhật',
|
'common.updated_at': 'Ngày cập nhật',
|
||||||
'common.undefined': 'Chưa xác định',
|
'common.undefined': 'Chưa xác định',
|
||||||
'common.not_empty': 'Không được để trống!',
|
'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.normal': 'Bình thường',
|
||||||
'common.level.warning': 'Cảnh báo',
|
'common.level.warning': 'Cảnh báo',
|
||||||
'common.level.critical': 'Nguy hiểm',
|
'common.level.critical': 'Nguy hiểm',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export default {
|
export default {
|
||||||
'menu.sgw.map': 'Bản đồ',
|
'menu.sgw.map': 'Bản đồ',
|
||||||
'menu.sgw.trips': 'Chuyến đi',
|
'menu.sgw.trips': 'Chuyến đi',
|
||||||
|
'menu.sgw.ships': 'Quản lý tàu',
|
||||||
'menu.manager.sgw.fishes': 'Loài cá',
|
'menu.manager.sgw.fishes': 'Loài cá',
|
||||||
'menu.manager.sgw.zones': 'Khu vực',
|
'menu.manager.sgw.zones': 'Khu vực',
|
||||||
};
|
};
|
||||||
|
|||||||
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/slave/sgw/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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -255,7 +255,7 @@ const ManagerDevicePage = () => {
|
|||||||
const offset = current === 1 ? 0 : (current - 1) * size;
|
const offset = current === 1 ? 0 : (current - 1) * size;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const metadata: Partial<MasterModel.ThingMetadata> = {};
|
const metadata: Partial<MasterModel.SearchThingMetadata> = {};
|
||||||
if (external_id) metadata.external_id = external_id;
|
if (external_id) metadata.external_id = external_id;
|
||||||
|
|
||||||
// Add group filter if groups are selected
|
// Add group filter if groups are selected
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const SystemLogs = () => {
|
|||||||
const tableRef = useRef<ActionType>();
|
const tableRef = useRef<ActionType>();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
const queryUserSource = async (): Promise<MasterModel.ProfileResponse[]> => {
|
const queryUserSource = async (): Promise<MasterModel.UserResponse[]> => {
|
||||||
try {
|
try {
|
||||||
const body: MasterModel.SearchUserPaginationBody = {
|
const body: MasterModel.SearchUserPaginationBody = {
|
||||||
offset: 0,
|
offset: 0,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ enum AssignTabsKey {
|
|||||||
const AssignUserPage = () => {
|
const AssignUserPage = () => {
|
||||||
const { userId } = useParams<{ userId: string }>();
|
const { userId } = useParams<{ userId: string }>();
|
||||||
const [userProfile, setUserProfile] =
|
const [userProfile, setUserProfile] =
|
||||||
useState<MasterModel.ProfileResponse | null>(null);
|
useState<MasterModel.UserResponse | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [tabSelected, setTabSelected] = useState<AssignTabsKey>(
|
const [tabSelected, setTabSelected] = useState<AssignTabsKey>(
|
||||||
AssignTabsKey.group,
|
AssignTabsKey.group,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
type AssignGroupProps = {
|
type AssignGroupProps = {
|
||||||
user: MasterModel.ProfileResponse | null;
|
user: MasterModel.UserResponse | null;
|
||||||
};
|
};
|
||||||
const AssignGroup = ({ user }: AssignGroupProps) => {
|
const AssignGroup = ({ user }: AssignGroupProps) => {
|
||||||
const groupActionRef = useRef<ActionType>();
|
const groupActionRef = useRef<ActionType>();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ type PolicyShareDefault = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ShareThingProps = {
|
type ShareThingProps = {
|
||||||
user: MasterModel.ProfileResponse | null;
|
user: MasterModel.UserResponse | null;
|
||||||
};
|
};
|
||||||
const ShareThing = ({ user }: ShareThingProps) => {
|
const ShareThing = ({ user }: ShareThingProps) => {
|
||||||
const listActionRef = useRef<ActionType>();
|
const listActionRef = useRef<ActionType>();
|
||||||
|
|||||||
@@ -34,19 +34,19 @@ const ManagerUserPage = () => {
|
|||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
const [selectedRowsState, setSelectedRowsState] = useState<
|
const [selectedRowsState, setSelectedRowsState] = useState<
|
||||||
MasterModel.ProfileResponse[]
|
MasterModel.UserResponse[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
const [groupCheckedKeys, setGroupCheckedKeys] = useState<
|
const [groupCheckedKeys, setGroupCheckedKeys] = useState<
|
||||||
string | string[] | null
|
string | string[] | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const handleClickAssign = (user: MasterModel.ProfileResponse) => {
|
const handleClickAssign = (user: MasterModel.UserResponse) => {
|
||||||
const path = `${ROUTE_MANAGER_USERS}/${user.id}/${ROUTE_MANAGER_USERS_PERMISSIONS}`;
|
const path = `${ROUTE_MANAGER_USERS}/${user.id}/${ROUTE_MANAGER_USERS_PERMISSIONS}`;
|
||||||
history.push(path);
|
history.push(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ProColumns<MasterModel.ProfileResponse>[] = [
|
const columns: ProColumns<MasterModel.UserResponse>[] = [
|
||||||
{
|
{
|
||||||
key: 'email',
|
key: 'email',
|
||||||
title: (
|
title: (
|
||||||
@@ -136,7 +136,7 @@ const ManagerUserPage = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleRemove = async (selectedRows: MasterModel.ProfileResponse[]) => {
|
const handleRemove = async (selectedRows: MasterModel.UserResponse[]) => {
|
||||||
const key = 'remove_user';
|
const key = 'remove_user';
|
||||||
if (!selectedRows) return true;
|
if (!selectedRows) return true;
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ const ManagerUserPage = () => {
|
|||||||
key,
|
key,
|
||||||
});
|
});
|
||||||
const allDelete = selectedRows.map(
|
const allDelete = selectedRows.map(
|
||||||
async (row: MasterModel.ProfileResponse) => {
|
async (row: MasterModel.UserResponse) => {
|
||||||
await apiDeleteUser(row?.id || '');
|
await apiDeleteUser(row?.id || '');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -196,7 +196,7 @@ const ManagerUserPage = () => {
|
|||||||
/>
|
/>
|
||||||
</ProCard>
|
</ProCard>
|
||||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 18, lg: 18, xl: 18 }}>
|
<ProCard colSpan={{ xs: 24, sm: 24, md: 18, lg: 18, xl: 18 }}>
|
||||||
<ProTable<MasterModel.ProfileResponse>
|
<ProTable<MasterModel.UserResponse>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
tableLayout="auto"
|
tableLayout="auto"
|
||||||
actionRef={actionRef}
|
actionRef={actionRef}
|
||||||
@@ -210,7 +210,7 @@ const ManagerUserPage = () => {
|
|||||||
selectedRowKeys: selectedRowsState.map((row) => row.id!),
|
selectedRowKeys: selectedRowsState.map((row) => row.id!),
|
||||||
onChange: (
|
onChange: (
|
||||||
_: React.Key[],
|
_: React.Key[],
|
||||||
selectedRows: MasterModel.ProfileResponse[],
|
selectedRows: MasterModel.UserResponse[],
|
||||||
) => {
|
) => {
|
||||||
setSelectedRowsState(selectedRows);
|
setSelectedRowsState(selectedRows);
|
||||||
},
|
},
|
||||||
@@ -249,12 +249,12 @@ const ManagerUserPage = () => {
|
|||||||
let users = userByGroupResponses.users || [];
|
let users = userByGroupResponses.users || [];
|
||||||
// Apply filters
|
// Apply filters
|
||||||
if (email) {
|
if (email) {
|
||||||
users = users.filter((user: MasterModel.ProfileResponse) =>
|
users = users.filter((user: MasterModel.UserResponse) =>
|
||||||
user.email?.includes(email),
|
user.email?.includes(email),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (phone_number) {
|
if (phone_number) {
|
||||||
users = users.filter((user: MasterModel.ProfileResponse) =>
|
users = users.filter((user: MasterModel.UserResponse) =>
|
||||||
user.metadata?.phone_number?.includes(phone_number),
|
user.metadata?.phone_number?.includes(phone_number),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -269,7 +269,7 @@ const ManagerUserPage = () => {
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Use regular queryUsers API
|
// Use regular queryUsers API
|
||||||
const metadata: Partial<MasterModel.ProfileMetadata> = {};
|
const metadata: Partial<MasterModel.UserMetadata> = {};
|
||||||
if (phone_number) metadata.phone_number = phone_number;
|
if (phone_number) metadata.phone_number = phone_number;
|
||||||
|
|
||||||
const query: MasterModel.SearchUserPaginationBody = {
|
const query: MasterModel.SearchUserPaginationBody = {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const ChangeProfile = () => {
|
|||||||
}}
|
}}
|
||||||
onFinish={async (values) => {
|
onFinish={async (values) => {
|
||||||
try {
|
try {
|
||||||
const body: Partial<MasterModel.ProfileMetadata> = {
|
const body: Partial<MasterModel.UserMetadata> = {
|
||||||
full_name: values.full_name,
|
full_name: values.full_name,
|
||||||
phone_number: values.phone_number,
|
phone_number: values.phone_number,
|
||||||
};
|
};
|
||||||
|
|||||||
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 photoData = await apiGetPhoto('ship', resp.id || '');
|
||||||
|
const blob = new Blob([photoData], { 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 SGWMap: React.FC = () => {
|
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 (
|
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();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
<div>
|
||||||
<h1>Bản đồ (SGW)</h1>
|
<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>
|
</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;
|
||||||
554
src/pages/Slave/SGW/Trip/components/CreateTrip.tsx
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
import { apiQueryShips } from '@/services/slave/sgw/ShipController';
|
||||||
|
import {
|
||||||
|
apiCreateTrip,
|
||||||
|
apiQueryLastTrips,
|
||||||
|
} from '@/services/slave/sgw/TripController';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import type {
|
||||||
|
ProFormInstance,
|
||||||
|
SubmitterProps,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import {
|
||||||
|
ModalForm,
|
||||||
|
ProForm,
|
||||||
|
ProFormDatePicker,
|
||||||
|
ProFormList,
|
||||||
|
ProFormSelect,
|
||||||
|
ProFormText,
|
||||||
|
StepsForm,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { FormattedMessage, useModel } from '@umijs/max';
|
||||||
|
import { Button, message, Table, Typography } from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
|
||||||
|
interface CreateTripProps {
|
||||||
|
onSuccess?: (ok: boolean) => void;
|
||||||
|
ship_id?: string;
|
||||||
|
thing_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripFormValues {
|
||||||
|
name: string;
|
||||||
|
departure_time: any; // dayjs object or string
|
||||||
|
departure_port_id: number;
|
||||||
|
arrival_time?: any; // dayjs object or string
|
||||||
|
arrival_port_id?: number;
|
||||||
|
fishing_ground_codes?: number[];
|
||||||
|
fishing_gear?: SgwModel.FishingGear[];
|
||||||
|
trip_cost?: SgwModel.TripCost[];
|
||||||
|
ship_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CreateTrip: React.FC<CreateTripProps> = ({ onSuccess, thing_id }) => {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [selectedThing, setSelectedThing] = useState<string | undefined>();
|
||||||
|
const [ships, setShips] = useState<SgwModel.ShipDetail[]>([]);
|
||||||
|
const [selectedShip, setSelectedShip] = useState<SgwModel.ShipDetail | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [lastTrip, setLastTrip] = useState<SgwModel.Trip | null>(null);
|
||||||
|
const [initialFormValues, setInitialFormValues] = useState<
|
||||||
|
Partial<TripFormValues>
|
||||||
|
>({});
|
||||||
|
const formRef = useRef<ProFormInstance<TripFormValues>>(null);
|
||||||
|
const { homeports, getHomeportsByProvinceCode } = useModel(
|
||||||
|
'slave.sgw.useHomePorts',
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getHomeportsByProvinceCode();
|
||||||
|
}, [getHomeportsByProvinceCode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedThing(thing_id);
|
||||||
|
}, [thing_id]);
|
||||||
|
|
||||||
|
// Load danh sách tàu
|
||||||
|
const loadShips = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const resp = await apiQueryShips({ offset: 0, limit: 100 });
|
||||||
|
setShips(resp.ships || []);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('Không thể tải danh sách tàu');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
loadShips();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
// Auto-fill form when initialFormValues changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (formRef.current && Object.keys(initialFormValues).length > 0) {
|
||||||
|
console.log('<27> InitialFormValues:', initialFormValues);
|
||||||
|
// Fill form after a short delay to ensure all steps are ready
|
||||||
|
setTimeout(() => {
|
||||||
|
if (formRef.current) {
|
||||||
|
console.log('<27> Filling form with values...');
|
||||||
|
formRef.current.setFieldsValue(initialFormValues);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [initialFormValues]);
|
||||||
|
|
||||||
|
// Load last trip when ship is selected
|
||||||
|
const loadLastTrip = async (thingId: string) => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const trip = await apiQueryLastTrips(thingId);
|
||||||
|
setLastTrip(trip);
|
||||||
|
// Prepare form values with last trip data
|
||||||
|
if (trip) {
|
||||||
|
console.log('📦 Raw trip data:', trip);
|
||||||
|
const formValues = {
|
||||||
|
name: trip.name,
|
||||||
|
departure_time: dayjs(trip.departure_time),
|
||||||
|
arrival_time: dayjs(trip.arrival_time),
|
||||||
|
departure_port_id: trip.departure_port_id,
|
||||||
|
arrival_port_id: trip.arrival_port_id,
|
||||||
|
fishing_ground_codes: trip.fishing_ground_codes || [],
|
||||||
|
fishing_gear: trip.fishing_gears || [],
|
||||||
|
trip_cost: trip.trip_cost || [],
|
||||||
|
};
|
||||||
|
console.log('📋 Prepared form values:', formValues);
|
||||||
|
setInitialFormValues(formValues);
|
||||||
|
|
||||||
|
message.success(
|
||||||
|
`Đã tải ${trip.fishing_gears?.length || 0} ngư cụ và ${
|
||||||
|
trip.trip_cost?.length || 0
|
||||||
|
} chi phí từ chuyến trước`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading last trip:', error);
|
||||||
|
setLastTrip(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (values: TripFormValues) => {
|
||||||
|
try {
|
||||||
|
if (!selectedShip) {
|
||||||
|
message.error('Vui lòng chọn tàu!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!values.departure_port_id) {
|
||||||
|
message.error('Vui lòng chọn cảng khởi hành!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!values.arrival_port_id) {
|
||||||
|
message.error('Vui lòng chọn cảng cập bến!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const params: SgwModel.TripCreateParams = {
|
||||||
|
name: values.name,
|
||||||
|
departure_time: dayjs(values.departure_time as string | Date)
|
||||||
|
.utc()
|
||||||
|
.format(),
|
||||||
|
departure_port_id: values.departure_port_id,
|
||||||
|
arrival_time: dayjs(values.arrival_time as string | Date)
|
||||||
|
.utc()
|
||||||
|
.format(),
|
||||||
|
arrival_port_id: values.arrival_port_id,
|
||||||
|
fishing_ground_codes: Array.isArray(values.fishing_ground_codes)
|
||||||
|
? (values.fishing_ground_codes as (string | number)[])
|
||||||
|
.map((code: string | number) => Number(code))
|
||||||
|
.filter((n: number) => !isNaN(n))
|
||||||
|
: [],
|
||||||
|
fishing_gears: values.fishing_gear,
|
||||||
|
trip_cost: values.trip_cost || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const thingIdToUse = selectedShip.thing_id || selectedThing;
|
||||||
|
if (!thingIdToUse) {
|
||||||
|
message.error('Không tìm thấy thiết bị của tàu!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await apiCreateTrip(thingIdToUse, params);
|
||||||
|
console.log('createTrip resp', resp);
|
||||||
|
if (resp && !resp?.trip_status) {
|
||||||
|
message.success('Tạo chuyến đi thành công!');
|
||||||
|
onSuccess?.(true);
|
||||||
|
setVisible(false);
|
||||||
|
setSelectedShip(null);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
message.error(`Tạo chuyến đi thất bại!`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Lỗi khi tạo chuyến đi:', err);
|
||||||
|
message.error('Tạo chuyến đi thất bại!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button type="primary" key="primary" onClick={() => setVisible(true)}>
|
||||||
|
<PlusOutlined />{' '}
|
||||||
|
<FormattedMessage
|
||||||
|
id="pages.things.createTrip.text"
|
||||||
|
defaultMessage="Tạo chuyến đi"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ModalForm
|
||||||
|
title="Tạo chuyến đi mới"
|
||||||
|
open={visible}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setVisible(open);
|
||||||
|
if (!open) {
|
||||||
|
setSelectedShip(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
modalProps={{
|
||||||
|
width: 900,
|
||||||
|
}}
|
||||||
|
layout="horizontal"
|
||||||
|
submitter={false}
|
||||||
|
>
|
||||||
|
<StepsForm<SgwModel.TripCreateParams>
|
||||||
|
stepsProps={{ size: 'small' }}
|
||||||
|
onFinish={(values) => handleSubmit(values as TripFormValues)}
|
||||||
|
submitter={{
|
||||||
|
render: (_: SubmitterProps, dom) => dom,
|
||||||
|
}}
|
||||||
|
formProps={{
|
||||||
|
layout: 'horizontal',
|
||||||
|
}}
|
||||||
|
formRef={formRef}
|
||||||
|
onCurrentChange={(current) => {
|
||||||
|
if (!lastTrip || !formRef.current) return;
|
||||||
|
|
||||||
|
if (current === 1) {
|
||||||
|
formRef.current.setFieldsValue({
|
||||||
|
name: lastTrip.name,
|
||||||
|
departure_time: dayjs(lastTrip.departure_time),
|
||||||
|
arrival_time: dayjs(lastTrip.arrival_time),
|
||||||
|
departure_port_id: lastTrip.departure_port_id,
|
||||||
|
arrival_port_id: lastTrip.arrival_port_id,
|
||||||
|
fishing_ground_codes: lastTrip.fishing_ground_codes || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current === 2) {
|
||||||
|
formRef.current.setFieldsValue({
|
||||||
|
fishing_gear: lastTrip.fishing_gears || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current === 3) {
|
||||||
|
formRef.current.setFieldsValue({
|
||||||
|
trip_cost: lastTrip.trip_cost || [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Step 1: Chọn tàu */}
|
||||||
|
<StepsForm.StepForm
|
||||||
|
title="Chọn tàu"
|
||||||
|
preserve
|
||||||
|
onFinish={async () => {
|
||||||
|
if (!selectedShip) {
|
||||||
|
message.error('Vui lòng chọn một tàu!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load last trip after selecting ship
|
||||||
|
const thingIdToUse = selectedShip.thing_id || selectedThing;
|
||||||
|
if (thingIdToUse) {
|
||||||
|
await loadLastTrip(thingIdToUse);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Title level={5}>Danh sách tàu</Typography.Title>
|
||||||
|
<Table
|
||||||
|
loading={loading}
|
||||||
|
dataSource={ships}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={{ pageSize: 5 }}
|
||||||
|
rowSelection={{
|
||||||
|
type: 'radio',
|
||||||
|
selectedRowKeys: selectedShip ? [selectedShip.id!] : [],
|
||||||
|
onChange: (
|
||||||
|
_: React.Key[],
|
||||||
|
selectedRows: SgwModel.ShipDetail[],
|
||||||
|
) => {
|
||||||
|
setSelectedShip(selectedRows[0] || null);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: 'Số đăng ký',
|
||||||
|
dataIndex: 'reg_number',
|
||||||
|
key: 'reg_number',
|
||||||
|
},
|
||||||
|
{ title: 'Tên tàu', dataIndex: 'name', key: 'name' },
|
||||||
|
{ title: 'Loại tàu', dataIndex: 'ship_type', key: 'ship_type' },
|
||||||
|
{ title: 'Cảng nhà', dataIndex: 'home_port', key: 'home_port' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</StepsForm.StepForm>
|
||||||
|
|
||||||
|
{/* Step 2: Thông tin chuyến đi */}
|
||||||
|
<StepsForm.StepForm
|
||||||
|
title="Thông tin chuyến đi"
|
||||||
|
name="trip_info"
|
||||||
|
preserve
|
||||||
|
>
|
||||||
|
<ProFormText
|
||||||
|
name="name"
|
||||||
|
label="Tên chuyến đi"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: 'Vui lòng nhập tên chuyến đi' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProForm.Group title="Thời gian chuyến đi">
|
||||||
|
<ProFormDatePicker
|
||||||
|
name="departure_time"
|
||||||
|
showTime
|
||||||
|
label="Thời gian bắt đầu"
|
||||||
|
width="md"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Vui lòng chọn thời gian bắt đầu',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validator: (_: unknown, value: unknown) => {
|
||||||
|
if (!value) return Promise.resolve();
|
||||||
|
const todayDate = dayjs().startOf('day');
|
||||||
|
const selectedDate = dayjs(value as string).startOf(
|
||||||
|
'day',
|
||||||
|
);
|
||||||
|
return selectedDate.isBefore(todayDate)
|
||||||
|
? Promise.reject(
|
||||||
|
new Error('Ngày bắt đầu không được trước hôm nay'),
|
||||||
|
)
|
||||||
|
: Promise.resolve();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormDatePicker
|
||||||
|
name="arrival_time"
|
||||||
|
showTime
|
||||||
|
label="Thời gian kết thúc"
|
||||||
|
width="md"
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Vui lòng chọn thời gian kết thúc',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ProForm.Group>
|
||||||
|
<ProForm.Group title="Cảng đi / Cảng đến">
|
||||||
|
<ProFormSelect
|
||||||
|
name="departure_port_id"
|
||||||
|
label="Cảng khởi hành"
|
||||||
|
width="md"
|
||||||
|
placeholder="Chọn cảng khởi hành"
|
||||||
|
options={
|
||||||
|
Array.isArray(homeports)
|
||||||
|
? homeports.map((p) => ({ label: p.name, value: p.id }))
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: 'Vui lòng chọn cảng khởi hành' },
|
||||||
|
]}
|
||||||
|
showSearch
|
||||||
|
/>
|
||||||
|
<ProFormSelect
|
||||||
|
name="arrival_port_id"
|
||||||
|
label="Cảng cập bến"
|
||||||
|
width="md"
|
||||||
|
placeholder="Chọn cảng cập bến"
|
||||||
|
options={
|
||||||
|
Array.isArray(homeports)
|
||||||
|
? homeports.map((p) => ({ label: p.name, value: p.id }))
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: 'Vui lòng chọn cảng cập bến' },
|
||||||
|
]}
|
||||||
|
showSearch
|
||||||
|
/>
|
||||||
|
</ProForm.Group>
|
||||||
|
<ProFormSelect
|
||||||
|
name="fishing_ground_codes"
|
||||||
|
label="Ô khai thác"
|
||||||
|
fieldProps={{
|
||||||
|
mode: 'tags',
|
||||||
|
style: { width: '100%' },
|
||||||
|
placeholder: 'Nhập các ô ngư trường, nhấn Enter để thêm',
|
||||||
|
}}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: 'Vui lòng nhập ít nhất một ô khai thác!',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
options={[]}
|
||||||
|
/>
|
||||||
|
</StepsForm.StepForm>
|
||||||
|
|
||||||
|
{/* Step 3: Danh sách ngư cụ */}
|
||||||
|
<StepsForm.StepForm
|
||||||
|
title="Danh sách ngư cụ"
|
||||||
|
name="fishing_gear_step"
|
||||||
|
preserve
|
||||||
|
>
|
||||||
|
<ProForm.Group title="Danh sách ngư cụ">
|
||||||
|
<ProFormList
|
||||||
|
name="fishing_gear"
|
||||||
|
creatorButtonProps={{ creatorButtonText: 'Thêm ngư cụ' }}
|
||||||
|
copyIconProps={{ tooltipText: 'Sao chép' }}
|
||||||
|
deleteIconProps={{ tooltipText: 'Xóa' }}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', marginBottom: 4 }}>
|
||||||
|
<Typography.Text
|
||||||
|
strong
|
||||||
|
style={{ width: 200, marginRight: 8 }}
|
||||||
|
>
|
||||||
|
Tên ngư cụ
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text strong style={{ width: 200 }}>
|
||||||
|
Số lượng
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex' }}>
|
||||||
|
<ProFormText
|
||||||
|
name="name"
|
||||||
|
fieldProps={{ style: { width: 200, marginRight: 8 } }}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="number"
|
||||||
|
fieldProps={{ style: { width: 200 } }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProFormList>
|
||||||
|
</ProForm.Group>
|
||||||
|
</StepsForm.StepForm>
|
||||||
|
|
||||||
|
{/* Step 4: Chi phí nguyên liệu */}
|
||||||
|
<StepsForm.StepForm
|
||||||
|
title="Chi phí nguyên liệu"
|
||||||
|
name="trip_cost_step"
|
||||||
|
preserve
|
||||||
|
>
|
||||||
|
<ProForm.Group title="Chi phí nguyên liệu">
|
||||||
|
<ProFormList
|
||||||
|
name="trip_cost"
|
||||||
|
creatorButtonProps={{ creatorButtonText: 'Thêm nguyên liệu' }}
|
||||||
|
copyIconProps={{ tooltipText: 'Sao chép' }}
|
||||||
|
deleteIconProps={{ tooltipText: 'Xóa' }}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', marginBottom: 4 }}>
|
||||||
|
<Typography.Text
|
||||||
|
strong
|
||||||
|
style={{ width: 120, marginRight: 8 }}
|
||||||
|
>
|
||||||
|
Loại
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text
|
||||||
|
strong
|
||||||
|
style={{ width: 100, marginRight: 8 }}
|
||||||
|
>
|
||||||
|
Số lượng
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text
|
||||||
|
strong
|
||||||
|
style={{ width: 100, marginRight: 8 }}
|
||||||
|
>
|
||||||
|
Đơn vị
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text
|
||||||
|
strong
|
||||||
|
style={{ width: 100, marginRight: 8 }}
|
||||||
|
>
|
||||||
|
Chi phí/đơn vị
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text strong style={{ width: 100 }}>
|
||||||
|
Tổng chi phí
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex' }}>
|
||||||
|
<ProFormSelect
|
||||||
|
name="type"
|
||||||
|
placeholder="Chọn loại"
|
||||||
|
fieldProps={{ style: { width: 120, marginRight: 8 } }}
|
||||||
|
options={[
|
||||||
|
{ label: 'Nhiên liệu', value: 'fuel' },
|
||||||
|
{ label: 'Lương thuyền viên', value: 'crew_salary' },
|
||||||
|
{ label: 'Lương thực', value: 'food' },
|
||||||
|
{ label: 'Muối đá', value: 'salt_ice' },
|
||||||
|
]}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: 'Vui lòng chọn loại' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="amount"
|
||||||
|
fieldProps={{
|
||||||
|
style: { width: 100, marginRight: 8 },
|
||||||
|
placeholder: 'Số lượng',
|
||||||
|
}}
|
||||||
|
rules={[{ required: true, message: 'Nhập số lượng' }]}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="unit"
|
||||||
|
fieldProps={{
|
||||||
|
style: { width: 100, marginRight: 8 },
|
||||||
|
placeholder: 'Đơn vị',
|
||||||
|
}}
|
||||||
|
rules={[{ required: true, message: 'Nhập đơn vị' }]}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="cost_per_unit"
|
||||||
|
fieldProps={{
|
||||||
|
style: { width: 100, marginRight: 8 },
|
||||||
|
placeholder: 'Chi phí',
|
||||||
|
}}
|
||||||
|
rules={[{ required: true, message: 'Nhập chi phí' }]}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="total_cost"
|
||||||
|
fieldProps={{
|
||||||
|
style: { width: 100 },
|
||||||
|
placeholder: 'Tổng',
|
||||||
|
}}
|
||||||
|
rules={[{ required: true, message: 'Nhập tổng' }]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ProFormList>
|
||||||
|
</ProForm.Group>
|
||||||
|
</StepsForm.StepForm>
|
||||||
|
</StepsForm>
|
||||||
|
</ModalForm>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateTrip;
|
||||||
177
src/pages/Slave/SGW/Trip/components/EditCrew.tsx
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
import {
|
||||||
|
apiUpdateCrew,
|
||||||
|
apiUpdateTripCrew,
|
||||||
|
} from '@/services/slave/sgw/TripController';
|
||||||
|
import {
|
||||||
|
ModalForm,
|
||||||
|
ProFormDatePicker,
|
||||||
|
ProFormSelect,
|
||||||
|
ProFormText,
|
||||||
|
ProFormTextArea,
|
||||||
|
} from '@ant-design/pro-form';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Form, message } from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface EditCrewProps {
|
||||||
|
record: SgwModel.TripCrews;
|
||||||
|
tripId: string;
|
||||||
|
visible: boolean;
|
||||||
|
onVisibleChange: (visible: boolean) => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditCrew: React.FC<EditCrewProps> = ({
|
||||||
|
record,
|
||||||
|
tripId,
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const formRef = useRef();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && record?.Person) {
|
||||||
|
const birthDate = record.Person.birth_date
|
||||||
|
? dayjs(record.Person.birth_date)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
form.setFieldsValue({
|
||||||
|
personal_id: record.Person.personal_id,
|
||||||
|
name: record.Person.name,
|
||||||
|
phone: record.Person.phone,
|
||||||
|
email: record.Person.email,
|
||||||
|
birth_date: birthDate && birthDate.isValid() ? birthDate : null,
|
||||||
|
address: record.Person.address,
|
||||||
|
note: record.Person.note,
|
||||||
|
role: record.role,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [visible, record, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalForm
|
||||||
|
form={form}
|
||||||
|
formRef={formRef}
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'pages.trips.crew.edit',
|
||||||
|
defaultMessage: 'Edit Crew',
|
||||||
|
})}
|
||||||
|
width="600px"
|
||||||
|
open={visible}
|
||||||
|
modalProps={{ destroyOnClose: true }}
|
||||||
|
onOpenChange={onVisibleChange}
|
||||||
|
submitter={{
|
||||||
|
searchConfig: {
|
||||||
|
submitText: 'Cập nhật',
|
||||||
|
},
|
||||||
|
submitButtonProps: {
|
||||||
|
loading,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
onFinish={async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
// Update crew information
|
||||||
|
const crewPayload: SgwModel.CrewUpdateParams = {
|
||||||
|
name: values.name,
|
||||||
|
phone: values.phone,
|
||||||
|
email: values.email,
|
||||||
|
birth_date:
|
||||||
|
values.birth_date && dayjs(values.birth_date).isValid()
|
||||||
|
? dayjs(values.birth_date).format('YYYY-MM-DD')
|
||||||
|
: undefined,
|
||||||
|
address: values.address,
|
||||||
|
note: values.note,
|
||||||
|
};
|
||||||
|
await apiUpdateCrew(record.Person.personal_id, crewPayload);
|
||||||
|
|
||||||
|
// Update trip crew role
|
||||||
|
const tripCrewPayload: SgwModel.TripCrewUpdateParams = {
|
||||||
|
trip_id: tripId,
|
||||||
|
personal_id: record.Person.personal_id,
|
||||||
|
role: values.role,
|
||||||
|
};
|
||||||
|
await apiUpdateTripCrew(tripCrewPayload);
|
||||||
|
|
||||||
|
message.success('Cập nhật thông tin thuyền viên thành công!');
|
||||||
|
onSuccess?.();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
message.error('Cập nhật thông tin thất bại');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormText
|
||||||
|
name="personal_id"
|
||||||
|
label="Mã định danh"
|
||||||
|
disabled
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập mã định danh' }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProFormText
|
||||||
|
name="name"
|
||||||
|
label="Tên thuyền viên"
|
||||||
|
placeholder="Nhập tên thuyền viên"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập tên thuyền viên' }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProFormText
|
||||||
|
name="phone"
|
||||||
|
label="Số điện thoại"
|
||||||
|
placeholder="Nhập số điện thoại"
|
||||||
|
rules={[
|
||||||
|
{ pattern: /^[0-9]{10,11}$/, message: 'Số điện thoại không hợp lệ' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProFormText
|
||||||
|
name="email"
|
||||||
|
label="Email"
|
||||||
|
placeholder="Nhập email"
|
||||||
|
rules={[{ type: 'email', message: 'Email không hợp lệ' }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProFormDatePicker
|
||||||
|
name="birth_date"
|
||||||
|
label="Ngày sinh"
|
||||||
|
placeholder="Chọn ngày sinh"
|
||||||
|
fieldProps={{
|
||||||
|
format: 'DD/MM/YYYY',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProFormText name="address" label="Địa chỉ" placeholder="Nhập địa chỉ" />
|
||||||
|
|
||||||
|
<ProFormSelect
|
||||||
|
name="role"
|
||||||
|
label="Vai trò"
|
||||||
|
placeholder="Chọn vai trò"
|
||||||
|
options={[
|
||||||
|
{ label: 'Thuyền trưởng', value: 'captain' },
|
||||||
|
{ label: 'Thuyền viên', value: 'crew' },
|
||||||
|
]}
|
||||||
|
rules={[{ required: true, message: 'Vui lòng chọn vai trò' }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProFormTextArea
|
||||||
|
name="note"
|
||||||
|
label="Ghi chú"
|
||||||
|
placeholder="Nhập ghi chú"
|
||||||
|
fieldProps={{
|
||||||
|
rows: 3,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditCrew;
|
||||||
306
src/pages/Slave/SGW/Trip/components/EditTrip.tsx
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
// EditTrip Component - Edit trip information
|
||||||
|
import { apiUpdateTrip } from '@/services/slave/sgw/TripController';
|
||||||
|
import {
|
||||||
|
ModalForm,
|
||||||
|
ProCard,
|
||||||
|
ProForm,
|
||||||
|
ProFormDatePicker,
|
||||||
|
ProFormDependency,
|
||||||
|
ProFormList,
|
||||||
|
ProFormSelect,
|
||||||
|
ProFormText,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { useModel } from '@umijs/max';
|
||||||
|
import { Form, message, Typography } from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
interface EditTripProps {
|
||||||
|
id: string;
|
||||||
|
ship_id?: string;
|
||||||
|
record?: SgwModel.Trip;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditTrip: React.FC<EditTripProps> = ({
|
||||||
|
id,
|
||||||
|
ship_id,
|
||||||
|
record,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const { homeports, getHomeportsByProvinceCode } = useModel(
|
||||||
|
'slave.sgw.useHomePorts',
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getHomeportsByProvinceCode();
|
||||||
|
}, [getHomeportsByProvinceCode]);
|
||||||
|
|
||||||
|
console.log('record', record);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (record && open) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
...record,
|
||||||
|
ship_id,
|
||||||
|
departure_port_id: record.departure_port_id,
|
||||||
|
arrival_port_id: record.arrival_port_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [record, open, ship_id, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalForm
|
||||||
|
title="Chỉnh sửa chuyến đi"
|
||||||
|
form={form}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
modalProps={{
|
||||||
|
destroyOnClose: true,
|
||||||
|
width: 1500,
|
||||||
|
}}
|
||||||
|
onFinish={async (values) => {
|
||||||
|
try {
|
||||||
|
if (!values.departure_port_id) {
|
||||||
|
message.error('Vui lòng chọn cảng khởi hành!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!values.arrival_port_id) {
|
||||||
|
message.error('Vui lòng chọn cảng cập bến!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log('Form values on submit:', values);
|
||||||
|
const params = {
|
||||||
|
name: values.name,
|
||||||
|
departure_time: dayjs(values.departure_time).utc().format(),
|
||||||
|
departure_port_id: values.departure_port_id,
|
||||||
|
arrival_time: dayjs(values.arrival_time).utc().format(),
|
||||||
|
arrival_port_id: values.arrival_port_id,
|
||||||
|
fishing_ground_codes: Array.isArray(values.fishing_ground_codes)
|
||||||
|
? values.fishing_ground_codes
|
||||||
|
.map((code: string | number) => Number(code))
|
||||||
|
.filter((n: number) => !isNaN(n))
|
||||||
|
: [],
|
||||||
|
fishing_gears: values.fishing_gears,
|
||||||
|
trip_cost: values.trip_cost,
|
||||||
|
};
|
||||||
|
await apiUpdateTrip(id, params);
|
||||||
|
message.success('Cập nhật chuyến đi thành công');
|
||||||
|
onSuccess?.();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
message.error('Cập nhật thất bại');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
layout="horizontal"
|
||||||
|
>
|
||||||
|
<ProCard split="vertical">
|
||||||
|
{/* Bên trái: Ngư cụ + chi phí */}
|
||||||
|
<ProCard colSpan="50%">
|
||||||
|
{/* Danh sách ngư cụ */}
|
||||||
|
<ProForm.Group title="Danh sách ngư cụ">
|
||||||
|
<ProFormList
|
||||||
|
name="fishing_gears"
|
||||||
|
creatorButtonProps={{ creatorButtonText: 'Thêm ngư cụ' }}
|
||||||
|
copyIconProps={{ tooltipText: 'Sao chép' }}
|
||||||
|
deleteIconProps={{ tooltipText: 'Xóa' }}
|
||||||
|
>
|
||||||
|
<ProFormDependency name={['name', 'number']}>
|
||||||
|
{() => (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', marginBottom: 4 }}>
|
||||||
|
<Typography.Text
|
||||||
|
strong
|
||||||
|
style={{ width: 200, marginRight: 8 }}
|
||||||
|
>
|
||||||
|
Tên
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text strong style={{ width: 200 }}>
|
||||||
|
Số lượng
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex' }}>
|
||||||
|
<ProFormText
|
||||||
|
name="name"
|
||||||
|
fieldProps={{
|
||||||
|
style: { width: 200, marginRight: 8 },
|
||||||
|
placeholder: 'Tên',
|
||||||
|
}}
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="number"
|
||||||
|
fieldProps={{
|
||||||
|
style: { width: 200 },
|
||||||
|
placeholder: 'Số lượng',
|
||||||
|
}}
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProFormDependency>
|
||||||
|
</ProFormList>
|
||||||
|
</ProForm.Group>
|
||||||
|
|
||||||
|
{/* Danh sách chi phí */}
|
||||||
|
<ProForm.Group title="Chi phí nguyên liệu">
|
||||||
|
<ProFormList
|
||||||
|
name="trip_cost"
|
||||||
|
creatorButtonProps={{ creatorButtonText: 'Thêm nguyên liệu' }}
|
||||||
|
copyIconProps={{ tooltipText: 'Sao chép' }}
|
||||||
|
deleteIconProps={{ tooltipText: 'Xóa' }}
|
||||||
|
>
|
||||||
|
<ProFormDependency
|
||||||
|
name={['type', 'amount', 'unit', 'cost_per_unit', 'total_cost']}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<div style={{ display: 'flex', marginBottom: 4 }}>
|
||||||
|
<Typography.Text
|
||||||
|
strong
|
||||||
|
style={{ width: 120, marginRight: 8 }}
|
||||||
|
>
|
||||||
|
Loại
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text
|
||||||
|
strong
|
||||||
|
style={{ width: 100, marginRight: 8 }}
|
||||||
|
>
|
||||||
|
Số lượng
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text
|
||||||
|
strong
|
||||||
|
style={{ width: 100, marginRight: 8 }}
|
||||||
|
>
|
||||||
|
Đơn vị
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text
|
||||||
|
strong
|
||||||
|
style={{ width: 100, marginRight: 8 }}
|
||||||
|
>
|
||||||
|
Chi phí
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text strong style={{ width: 100 }}>
|
||||||
|
Tổng chi phí
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex' }}>
|
||||||
|
<ProFormSelect
|
||||||
|
name="type"
|
||||||
|
placeholder="Chọn loại"
|
||||||
|
fieldProps={{ style: { width: 120, marginRight: 8 } }}
|
||||||
|
options={[
|
||||||
|
{ label: 'Nhiên liệu', value: 'fuel' },
|
||||||
|
{ label: 'Lương thuyền viên', value: 'crew_salary' },
|
||||||
|
{ label: 'Lương thực', value: 'food' },
|
||||||
|
{ label: 'Muối đá', value: 'salt_ice' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="amount"
|
||||||
|
fieldProps={{ style: { width: 100, marginRight: 8 } }}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="unit"
|
||||||
|
fieldProps={{ style: { width: 100, marginRight: 8 } }}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="cost_per_unit"
|
||||||
|
fieldProps={{ style: { width: 100, marginRight: 8 } }}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="total_cost"
|
||||||
|
fieldProps={{ style: { width: 100 } }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ProFormDependency>
|
||||||
|
</ProFormList>
|
||||||
|
</ProForm.Group>
|
||||||
|
</ProCard>
|
||||||
|
|
||||||
|
{/* Bên phải: Thông tin chuyến đi */}
|
||||||
|
<ProCard colSpan="50%" bodyStyle={{ padding: '0 24px' }}>
|
||||||
|
<ProFormText
|
||||||
|
name="name"
|
||||||
|
label="Tên chuyến đi"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProForm.Group title="Thời gian chuyến đi">
|
||||||
|
<ProFormDatePicker
|
||||||
|
name="departure_time"
|
||||||
|
showTime
|
||||||
|
label="Bắt đầu"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
/>
|
||||||
|
<ProFormDatePicker
|
||||||
|
name="arrival_time"
|
||||||
|
showTime
|
||||||
|
label="Kết thúc"
|
||||||
|
rules={[{ required: true }]}
|
||||||
|
/>
|
||||||
|
</ProForm.Group>
|
||||||
|
|
||||||
|
<ProForm.Group title="Cảng">
|
||||||
|
<ProFormSelect
|
||||||
|
name="departure_port_id"
|
||||||
|
label="Cảng khởi hành"
|
||||||
|
width="md"
|
||||||
|
placeholder="Chọn cảng khởi hành"
|
||||||
|
options={
|
||||||
|
Array.isArray(homeports)
|
||||||
|
? homeports.map((p) => ({ label: p.name, value: p.id }))
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: 'Vui lòng chọn cảng khởi hành' },
|
||||||
|
]}
|
||||||
|
showSearch
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProFormSelect
|
||||||
|
name="arrival_port_id"
|
||||||
|
label="Cảng cập bến"
|
||||||
|
width="md"
|
||||||
|
placeholder="Chọn cảng cập bến"
|
||||||
|
options={
|
||||||
|
Array.isArray(homeports)
|
||||||
|
? homeports.map((p) => ({ label: p.name, value: p.id }))
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: 'Vui lòng chọn cảng cập bến' },
|
||||||
|
]}
|
||||||
|
showSearch
|
||||||
|
/>
|
||||||
|
</ProForm.Group>
|
||||||
|
|
||||||
|
<ProForm.Group title="Thông tin cơ bản">
|
||||||
|
<ProFormSelect
|
||||||
|
name="fishing_ground_codes"
|
||||||
|
label="Ô khai thác"
|
||||||
|
fieldProps={{ mode: 'tags', style: { width: '100%' } }}
|
||||||
|
/>
|
||||||
|
</ProForm.Group>
|
||||||
|
</ProCard>
|
||||||
|
</ProCard>
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditTrip;
|
||||||
209
src/pages/Slave/SGW/Trip/components/FormAddCrew.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import {
|
||||||
|
apiAddTripCrew,
|
||||||
|
apiCreateCrew,
|
||||||
|
apiGetCrew,
|
||||||
|
} from '@/services/slave/sgw/TripController';
|
||||||
|
import { ProForm, ProFormSelect } from '@ant-design/pro-components';
|
||||||
|
import {
|
||||||
|
ModalForm,
|
||||||
|
ProFormDatePicker,
|
||||||
|
ProFormText,
|
||||||
|
} from '@ant-design/pro-form';
|
||||||
|
import { message } from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
|
||||||
|
interface AddCrewProps {
|
||||||
|
tripId: string;
|
||||||
|
visible: boolean;
|
||||||
|
onVisibleChange: (visible: boolean) => void;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
personal_id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email?: string;
|
||||||
|
birth_date: string;
|
||||||
|
note?: string;
|
||||||
|
address: string;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddCrew: React.FC<AddCrewProps> = ({
|
||||||
|
tripId,
|
||||||
|
visible,
|
||||||
|
onVisibleChange,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const [form] = ProForm.useForm();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchCrew = async (personalId: string) => {
|
||||||
|
if (!personalId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const crew = await apiGetCrew(personalId);
|
||||||
|
if (crew) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
name: crew.name,
|
||||||
|
personal_id: crew.personal_id,
|
||||||
|
birth_date: crew.birth_date
|
||||||
|
? crew.birth_date.toString().split('T')[0]
|
||||||
|
: null,
|
||||||
|
phone: crew.phone,
|
||||||
|
address: crew.address,
|
||||||
|
});
|
||||||
|
message.success('Đã tìm thấy thuyền viên, dữ liệu được điền tự động');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as { response?: { status: number } };
|
||||||
|
if (err?.response?.status === 404) {
|
||||||
|
// Không tìm thấy
|
||||||
|
form.resetFields(['name', 'birth_date', 'phone', 'address']);
|
||||||
|
form.setFieldValue('personal_id', personalId);
|
||||||
|
|
||||||
|
message.info('Chưa có thuyền viên này, vui lòng nhập mới');
|
||||||
|
} else {
|
||||||
|
message.error('Lỗi khi kiểm tra thuyền viên');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debounce để tránh gọi API liên tục khi gõ
|
||||||
|
const handlePersonalIdChange = useCallback(
|
||||||
|
(() => {
|
||||||
|
let timeoutId: NodeJS.Timeout;
|
||||||
|
return (value: string) => {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
fetchCrew(value);
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
})(),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = async (values: FormValues) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const { personal_id, name, phone, email, birth_date, note, address } =
|
||||||
|
values;
|
||||||
|
|
||||||
|
const crewPayload: SgwModel.CrewCreateParams = {
|
||||||
|
personal_id,
|
||||||
|
name,
|
||||||
|
phone,
|
||||||
|
email,
|
||||||
|
birth_date: birth_date
|
||||||
|
? dayjs(birth_date).format('YYYY-MM-DDT00:00:00[Z]')
|
||||||
|
: undefined,
|
||||||
|
note,
|
||||||
|
address,
|
||||||
|
};
|
||||||
|
|
||||||
|
let crewId = personal_id;
|
||||||
|
|
||||||
|
console.log('addTripCrew payload:', {
|
||||||
|
trip_id: tripId,
|
||||||
|
personal_id: crewId,
|
||||||
|
role: values.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await apiGetCrew(personal_id);
|
||||||
|
} catch (error) {
|
||||||
|
const err = error as { response?: { status: number } };
|
||||||
|
if (err?.response?.status === 404) {
|
||||||
|
const newCrew = await apiCreateCrew(crewPayload);
|
||||||
|
console.log('newCrew result:', newCrew);
|
||||||
|
crewId = newCrew.personal_id || personal_id;
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('addTripCrew payload:', {
|
||||||
|
trip_id: tripId,
|
||||||
|
personal_id: personal_id,
|
||||||
|
role: values.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
await apiAddTripCrew({
|
||||||
|
trip_id: tripId,
|
||||||
|
personal_id: crewId,
|
||||||
|
role: values.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success('Thêm thuyền viên vào chuyến thành công!');
|
||||||
|
onSuccess?.();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
message.error('Có lỗi xảy ra khi thêm thuyền viên');
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalForm
|
||||||
|
title="Thêm thuyền viên"
|
||||||
|
form={form}
|
||||||
|
open={visible}
|
||||||
|
modalProps={{
|
||||||
|
onCancel: () => onVisibleChange(false),
|
||||||
|
destroyOnClose: true,
|
||||||
|
}}
|
||||||
|
onOpenChange={onVisibleChange}
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
submitter={{
|
||||||
|
searchConfig: {
|
||||||
|
submitText: 'Đồng ý',
|
||||||
|
},
|
||||||
|
submitButtonProps: {
|
||||||
|
loading,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormText
|
||||||
|
name="personal_id"
|
||||||
|
label="Số định danh"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập số định danh' }]}
|
||||||
|
fieldProps={{
|
||||||
|
onChange: (e) => handlePersonalIdChange(e.target.value),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="name"
|
||||||
|
label="Họ và tên"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập họ tên' }]}
|
||||||
|
/>
|
||||||
|
<ProFormDatePicker
|
||||||
|
name="birth_date"
|
||||||
|
label="Ngày sinh"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng chọn ngày sinh' }]}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="phone"
|
||||||
|
label="Số điện thoại"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập số điện thoại' }]}
|
||||||
|
/>
|
||||||
|
<ProFormText name="address" label="Địa chỉ" />
|
||||||
|
<ProFormSelect
|
||||||
|
name="role"
|
||||||
|
label="Vai trò"
|
||||||
|
placeholder="Chọn vai trò"
|
||||||
|
options={[
|
||||||
|
{ label: 'Thuyền trưởng', value: 'captain' },
|
||||||
|
{ label: 'Thuyền viên', value: 'crew' },
|
||||||
|
]}
|
||||||
|
rules={[{ required: true, message: 'Vui lòng chọn vai trò' }]}
|
||||||
|
/>
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddCrew;
|
||||||
84
src/pages/Slave/SGW/Trip/components/HaulFishList.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { ProCard, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Modal } from 'antd';
|
||||||
|
|
||||||
|
interface HaulFishListProp {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
fishList?: SgwModel.FishingLogInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const HaulFishList: React.FC<HaulFishListProp> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
fishList,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const fish_columns: ProColumns<SgwModel.FishingLogInfo>[] = [
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'trip.haulFishList.fishName' }),
|
||||||
|
dataIndex: 'fish_name',
|
||||||
|
key: 'fish_name',
|
||||||
|
render: (value) => value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'trip.haulFishList.fishCondition' }),
|
||||||
|
dataIndex: 'fish_condition',
|
||||||
|
key: 'fish_condition',
|
||||||
|
render: (value) => value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'trip.haulFishList.fishRarity' }),
|
||||||
|
dataIndex: 'fish_rarity',
|
||||||
|
key: 'fish_rarity',
|
||||||
|
render: (value) => value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'trip.haulFishList.fishSize' }),
|
||||||
|
dataIndex: 'fish_size',
|
||||||
|
key: 'fish_size',
|
||||||
|
render: (value) => value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'trip.haulFishList.weight' }),
|
||||||
|
dataIndex: 'catch_number',
|
||||||
|
key: 'catch_number',
|
||||||
|
render: (value) => value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({ id: 'trip.haulFishList.gearUsage' }),
|
||||||
|
dataIndex: 'gear_usage',
|
||||||
|
key: 'gear_usage',
|
||||||
|
render: (value) => value,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={intl.formatMessage({ id: 'trip.haulFishList.title' })}
|
||||||
|
open={open}
|
||||||
|
footer={null}
|
||||||
|
closable
|
||||||
|
afterClose={() => onOpenChange(false)}
|
||||||
|
onCancel={() => onOpenChange(false)}
|
||||||
|
width={1000}
|
||||||
|
>
|
||||||
|
<ProCard split="vertical">
|
||||||
|
<ProTable<SgwModel.FishingLogInfo>
|
||||||
|
columns={fish_columns}
|
||||||
|
dataSource={fishList}
|
||||||
|
search={false}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 5,
|
||||||
|
showSizeChanger: true,
|
||||||
|
}}
|
||||||
|
options={false}
|
||||||
|
bordered
|
||||||
|
size="middle"
|
||||||
|
scroll={{ x: 600 }}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HaulFishList;
|
||||||
77
src/pages/Slave/SGW/Trip/components/ListSkeleton.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Col, Row, Skeleton } from 'antd';
|
||||||
|
const ListSkeleton = ({}) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row justify="space-between">
|
||||||
|
<Col span={2}>
|
||||||
|
<Skeleton.Avatar active={true} size="small" />
|
||||||
|
</Col>
|
||||||
|
<Col span={16}>
|
||||||
|
<Skeleton.Input active={true} size="small" block={true} />
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Skeleton.Button
|
||||||
|
active={true}
|
||||||
|
size="small"
|
||||||
|
shape="round"
|
||||||
|
block={true}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<p />
|
||||||
|
<Row justify="space-between">
|
||||||
|
<Col span={2}>
|
||||||
|
<Skeleton.Avatar active={true} size="small" />
|
||||||
|
</Col>
|
||||||
|
<Col span={16}>
|
||||||
|
<Skeleton.Input active={true} size="small" block={true} />
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Skeleton.Button
|
||||||
|
active={true}
|
||||||
|
size="small"
|
||||||
|
shape="round"
|
||||||
|
block={true}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<p />
|
||||||
|
<Row justify="space-between">
|
||||||
|
<Col span={2}>
|
||||||
|
<Skeleton.Avatar active={true} size="small" />
|
||||||
|
</Col>
|
||||||
|
<Col span={16}>
|
||||||
|
<Skeleton.Input active={true} size="small" block={true} />
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Skeleton.Button
|
||||||
|
active={true}
|
||||||
|
size="small"
|
||||||
|
shape="round"
|
||||||
|
block={true}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<p />
|
||||||
|
<Row justify="space-between">
|
||||||
|
<Col span={2}>
|
||||||
|
<Skeleton.Avatar active={true} size="small" />
|
||||||
|
</Col>
|
||||||
|
<Col span={16}>
|
||||||
|
<Skeleton.Input active={true} size="small" block={true} />
|
||||||
|
</Col>
|
||||||
|
<Col span={4}>
|
||||||
|
<Skeleton.Button
|
||||||
|
active={true}
|
||||||
|
size="small"
|
||||||
|
shape="round"
|
||||||
|
block={true}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<p />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ListSkeleton;
|
||||||
119
src/pages/Slave/SGW/Trip/components/TripCost.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Typography } from 'antd';
|
||||||
|
|
||||||
|
interface TripCostTableProps {
|
||||||
|
tripCosts: SgwModel.TripCost[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TripCostTable: React.FC<TripCostTableProps> = ({ tripCosts }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
// Tính tổng chi phí
|
||||||
|
const total_trip_cost = tripCosts.reduce(
|
||||||
|
(sum, item) => sum + (Number(item.total_cost) || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const trip_cost_columns: ProColumns<SgwModel.TripCost>[] = [
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
title: (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
{intl.formatMessage({ id: 'trip.cost.type' })}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
dataIndex: 'type',
|
||||||
|
valueEnum: {
|
||||||
|
fuel: { text: intl.formatMessage({ id: 'trip.cost.fuel' }) },
|
||||||
|
crew_salary: {
|
||||||
|
text: intl.formatMessage({ id: 'trip.cost.crewSalary' }),
|
||||||
|
},
|
||||||
|
food: { text: intl.formatMessage({ id: 'trip.cost.food' }) },
|
||||||
|
ice_salt_cost: {
|
||||||
|
text: intl.formatMessage({ id: 'trip.cost.iceSalt' }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'amount',
|
||||||
|
title: (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
{intl.formatMessage({ id: 'trip.cost.amount' })}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
dataIndex: 'amount',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'unit',
|
||||||
|
title: (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
{intl.formatMessage({ id: 'trip.cost.unit' })}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
dataIndex: 'unit',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'cost_per_unit',
|
||||||
|
title: (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
{intl.formatMessage({ id: 'trip.cost.price' })}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
dataIndex: 'cost_per_unit',
|
||||||
|
align: 'center',
|
||||||
|
render: (val: any) => (val ? Number(val).toLocaleString() : ''),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'total_cost',
|
||||||
|
title: (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
{intl.formatMessage({ id: 'trip.cost.total' })}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
dataIndex: 'total_cost',
|
||||||
|
align: 'center',
|
||||||
|
render: (val: any) =>
|
||||||
|
val ? (
|
||||||
|
<b style={{ color: '#fa541c' }}>{Number(val).toLocaleString()}</b>
|
||||||
|
) : (
|
||||||
|
''
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<ProTable<SgwModel.TripCost>
|
||||||
|
rowKey="trip-cost-table"
|
||||||
|
columns={trip_cost_columns}
|
||||||
|
dataSource={tripCosts}
|
||||||
|
search={false}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 5,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total, range) => `${range[0]}-${range[1]} trên ${total}`,
|
||||||
|
}}
|
||||||
|
options={false}
|
||||||
|
// bordered
|
||||||
|
size="middle"
|
||||||
|
scroll={{ x: '100%' }}
|
||||||
|
style={{ flex: 1, width: '100%' }}
|
||||||
|
summary={() => (
|
||||||
|
<ProTable.Summary.Row>
|
||||||
|
<ProTable.Summary.Cell index={0} colSpan={4} align="right">
|
||||||
|
<Typography.Text strong style={{ color: '#1890ff' }}>
|
||||||
|
{intl.formatMessage({ id: 'trip.cost.grandTotal' })}
|
||||||
|
</Typography.Text>
|
||||||
|
</ProTable.Summary.Cell>
|
||||||
|
<ProTable.Summary.Cell index={4} align="center">
|
||||||
|
<Typography.Text strong style={{ color: '#fa541c', fontSize: 16 }}>
|
||||||
|
{total_trip_cost.toLocaleString()}
|
||||||
|
</Typography.Text>
|
||||||
|
</ProTable.Summary.Cell>
|
||||||
|
</ProTable.Summary.Row>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TripCostTable;
|
||||||
471
src/pages/Slave/SGW/Trip/components/TripCrews.tsx
Normal file
@@ -0,0 +1,471 @@
|
|||||||
|
import { DeleteButton, EditButton } from '@/components/shared/Button';
|
||||||
|
import {
|
||||||
|
apiGetPhoto,
|
||||||
|
apiUploadPhoto,
|
||||||
|
} from '@/services/slave/sgw/PhotoController';
|
||||||
|
import {
|
||||||
|
apiDeleteTripCrew,
|
||||||
|
apiGetTripCrew,
|
||||||
|
} from '@/services/slave/sgw/TripController';
|
||||||
|
import { PlusOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
|
import type { ActionType } from '@ant-design/pro-components';
|
||||||
|
import {
|
||||||
|
ModalForm,
|
||||||
|
ProCard,
|
||||||
|
ProColumns,
|
||||||
|
ProForm,
|
||||||
|
ProTable,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||||
|
import { Button, Divider, message, Modal, Tooltip, Upload } from 'antd';
|
||||||
|
import type { UploadFile } from 'antd/es/upload/interface';
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import EditCrew from './EditCrew';
|
||||||
|
import AddCrew from './FormAddCrew';
|
||||||
|
import UploadPhoto from './UploadPhoto';
|
||||||
|
|
||||||
|
interface TripCrewsProps {
|
||||||
|
record: SgwModel.Trip;
|
||||||
|
onChange?: (items: SgwModel.TripCrews[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
// Stub API functions - Now using real APIs with proper interfaces
|
||||||
|
const getTripCrew = async (
|
||||||
|
tripId: string,
|
||||||
|
): Promise<SgwModel.TripCrewQueryResponse> => {
|
||||||
|
return apiGetTripCrew(tripId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteTripCrew = async (
|
||||||
|
tripId: string,
|
||||||
|
personalId: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
return apiDeleteTripCrew(tripId, personalId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadPhoto = async (
|
||||||
|
type: 'people',
|
||||||
|
id: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<void> => {
|
||||||
|
return apiUploadPhoto(type, id, file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Component riêng để hiển thị ảnh thuyền viên
|
||||||
|
const CrewPhoto: React.FC<{ record: SgwModel.TripCrews }> = ({ record }) => {
|
||||||
|
const [photoSrc, setPhotoSrc] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Placeholder SVG for when there's no image or error
|
||||||
|
const placeholderSrc =
|
||||||
|
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTUwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTUwIiBoZWlnaHQ9IjEwMCIgZmlsbD0iI2Y1ZjVmNSIvPjx0ZXh0IHg9IjUwJSIgeT0iNTAlIiBmb250LWZhbWlseT0iQXJpYWwiIGZvbnQtc2l6ZT0iMTIiIGZpbGw9IiM5OTkiIHRleHQtYW5jaG9yPSJtaWRkbGUiIGR5PSIuM2VtIj5ObyBJbWFnZTwvdGV4dD48L3N2Zz4=';
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const loadPhoto = async () => {
|
||||||
|
if (!record.Person?.personal_id) {
|
||||||
|
setPhotoSrc(placeholderSrc);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const photoData = await apiGetPhoto(
|
||||||
|
'people',
|
||||||
|
record.Person.personal_id,
|
||||||
|
);
|
||||||
|
const blob = new Blob([photoData], { type: 'image/jpeg' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
setPhotoSrc(url);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load photo:', error);
|
||||||
|
setPhotoSrc(placeholderSrc);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadPhoto();
|
||||||
|
|
||||||
|
// Cleanup function to revoke object URL
|
||||||
|
return () => {
|
||||||
|
if (photoSrc && photoSrc.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(photoSrc);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [record.Person?.personal_id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<img
|
||||||
|
src={photoSrc || placeholderSrc}
|
||||||
|
alt={`Ảnh thuyền viên ${record.Person?.name || ''}`}
|
||||||
|
style={{
|
||||||
|
width: '150px',
|
||||||
|
height: '100px',
|
||||||
|
objectFit: 'cover',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: '1px solid #d9d9d9',
|
||||||
|
cursor: 'pointer',
|
||||||
|
opacity: loading ? 0.5 : 1,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TripCrews: React.FC<TripCrewsProps> = ({ record, onChange }) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const actionRef = useRef<ActionType>();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedItems] = useState<SgwModel.TripCrews[]>([]);
|
||||||
|
|
||||||
|
const [uploadPhotoModalVisible, handleUploadPhotoModalVisible] =
|
||||||
|
useState(false);
|
||||||
|
const [currentRow, setCurrentRow] = useState<SgwModel.TripCrews | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||||
|
const [addCrewModalVisible, setAddCrewModalVisible] = useState(false);
|
||||||
|
const [editCrewModalVisible, setEditCrewModalVisible] = useState(false);
|
||||||
|
|
||||||
|
// Handle upload photo
|
||||||
|
const handleUploadPhoto = async () => {
|
||||||
|
const key = 'upload_photo';
|
||||||
|
try {
|
||||||
|
message.loading({ content: 'Đang upload ảnh...', key, duration: 0 });
|
||||||
|
if (fileList.length > 0 && currentRow?.Person?.personal_id) {
|
||||||
|
const file = fileList[0].originFileObj || fileList[0];
|
||||||
|
await uploadPhoto(
|
||||||
|
'people',
|
||||||
|
currentRow.Person.personal_id,
|
||||||
|
file as File,
|
||||||
|
);
|
||||||
|
message.success({ content: 'Upload ảnh thành công!', key });
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
message.error({ content: 'Vui lòng chọn ảnh!', key });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
message.error({ content: 'Upload ảnh thất bại!', key });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadChange = ({
|
||||||
|
fileList: newFileList,
|
||||||
|
}: {
|
||||||
|
fileList: UploadFile[];
|
||||||
|
}) => {
|
||||||
|
setFileList(newFileList);
|
||||||
|
};
|
||||||
|
|
||||||
|
const beforeUpload = (file: File) => {
|
||||||
|
const isImage = file.type.startsWith('image/');
|
||||||
|
if (!isImage) {
|
||||||
|
message.error('Chỉ cho phép upload ảnh!');
|
||||||
|
}
|
||||||
|
const isLt5M = file.size / 1024 / 1024 < 5;
|
||||||
|
if (!isLt5M) {
|
||||||
|
message.error('Ảnh phải nhỏ hơn 5MB!');
|
||||||
|
}
|
||||||
|
return isImage && isLt5M;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOk = () => {
|
||||||
|
if (onChange) {
|
||||||
|
onChange(selectedItems);
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ProColumns<SgwModel.TripCrews>[] = [
|
||||||
|
{
|
||||||
|
title: 'Mã định danh',
|
||||||
|
dataIndex: ['Person', 'personal_id'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tên thuyền viên',
|
||||||
|
dataIndex: ['Person', 'name'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SĐT',
|
||||||
|
dataIndex: ['Person', 'phone'],
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Vai trò',
|
||||||
|
dataIndex: 'role', // ✅ Lấy trực tiếp từ TripCrews.role
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (val: unknown) => {
|
||||||
|
const role = val as string;
|
||||||
|
switch (role) {
|
||||||
|
case 'captain':
|
||||||
|
return <span style={{ color: 'blue' }}>Thuyền trưởng</span>;
|
||||||
|
case 'crew':
|
||||||
|
return <span style={{ color: 'green' }}>Thuyền viên</span>;
|
||||||
|
default:
|
||||||
|
return <span>{role}</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'image',
|
||||||
|
title: 'Hình ảnh',
|
||||||
|
dataIndex: 'image',
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, crewRecord) => <CrewPhoto record={crewRecord} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: (
|
||||||
|
<FormattedMessage id="pages.things.option" defaultMessage="Operating" />
|
||||||
|
),
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, crewRecord) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<EditButton
|
||||||
|
text="Edit"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentRow(crewRecord);
|
||||||
|
setEditCrewModalVisible(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider type="vertical" />
|
||||||
|
|
||||||
|
<UploadPhoto
|
||||||
|
text={intl.formatMessage({
|
||||||
|
id: 'pages.ship.update.photo',
|
||||||
|
defaultMessage: 'Edit Photo',
|
||||||
|
})}
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentRow(crewRecord);
|
||||||
|
handleUploadPhotoModalVisible(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider type="vertical" />
|
||||||
|
|
||||||
|
<DeleteButton
|
||||||
|
title="Bạn có chắc muốn xoá người này không?"
|
||||||
|
text="Delete"
|
||||||
|
onOk={async () => {
|
||||||
|
try {
|
||||||
|
if (crewRecord.Person?.personal_id && record.id) {
|
||||||
|
await deleteTripCrew(
|
||||||
|
record.id,
|
||||||
|
crewRecord.Person.personal_id,
|
||||||
|
);
|
||||||
|
message.success('Xoá thành công');
|
||||||
|
actionRef.current?.reload();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete error:', err);
|
||||||
|
message.error('Xoá thất bại');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'pages.trips.crew.title',
|
||||||
|
defaultMessage: 'Danh sách thành viên',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
key="listCrew"
|
||||||
|
size="small"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
icon={<UserOutlined />}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="Danh sách thành viên"
|
||||||
|
onOk={handleOk}
|
||||||
|
onCancel={() => setOpen(false)}
|
||||||
|
width={1000}
|
||||||
|
>
|
||||||
|
<ProCard split="vertical" bodyStyle={{ padding: 0 }}>
|
||||||
|
<ProCard bodyStyle={{ padding: 0 }}>
|
||||||
|
<ProTable<SgwModel.TripCrews>
|
||||||
|
tableLayout="auto"
|
||||||
|
actionRef={actionRef}
|
||||||
|
columns={columns}
|
||||||
|
search={{
|
||||||
|
labelWidth: 'auto',
|
||||||
|
span: 12,
|
||||||
|
}}
|
||||||
|
pagination={{ pageSize: PAGE_SIZE }}
|
||||||
|
request={async (params) => {
|
||||||
|
const resp = await getTripCrew(record.id);
|
||||||
|
let crews = resp?.trip_crews || [];
|
||||||
|
if (params.name) {
|
||||||
|
crews = crews.filter((c) =>
|
||||||
|
c.Person?.name
|
||||||
|
?.toLowerCase()
|
||||||
|
.includes(params.name.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: crews,
|
||||||
|
total: crews.length,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
key="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setAddCrewModalVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserOutlined />{' '}
|
||||||
|
<FormattedMessage
|
||||||
|
id="pages.things.create.text"
|
||||||
|
defaultMessage="New"
|
||||||
|
/>
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
rowKey="id"
|
||||||
|
dateFormatter="string"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{addCrewModalVisible && (
|
||||||
|
<AddCrew
|
||||||
|
tripId={record?.id}
|
||||||
|
visible={addCrewModalVisible}
|
||||||
|
onVisibleChange={setAddCrewModalVisible}
|
||||||
|
onSuccess={() => {
|
||||||
|
setAddCrewModalVisible(false);
|
||||||
|
actionRef.current?.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editCrewModalVisible && currentRow && (
|
||||||
|
<EditCrew
|
||||||
|
record={currentRow}
|
||||||
|
tripId={record.id}
|
||||||
|
visible={editCrewModalVisible}
|
||||||
|
onVisibleChange={(visible) => {
|
||||||
|
setEditCrewModalVisible(visible);
|
||||||
|
if (!visible) setCurrentRow(undefined);
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
setEditCrewModalVisible(false);
|
||||||
|
setCurrentRow(undefined);
|
||||||
|
actionRef.current?.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ProCard>
|
||||||
|
</ProCard>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{uploadPhotoModalVisible && currentRow && (
|
||||||
|
<ModalForm
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'pages.ship.upload.photo.title',
|
||||||
|
defaultMessage: 'Upload Ship Photo',
|
||||||
|
})}
|
||||||
|
width="400px"
|
||||||
|
open={uploadPhotoModalVisible}
|
||||||
|
onOpenChange={(visible) => {
|
||||||
|
if (!visible) {
|
||||||
|
setCurrentRow(undefined);
|
||||||
|
setFileList([]);
|
||||||
|
}
|
||||||
|
handleUploadPhotoModalVisible(visible);
|
||||||
|
}}
|
||||||
|
onFinish={async () => {
|
||||||
|
const success = await handleUploadPhoto();
|
||||||
|
if (success) {
|
||||||
|
handleUploadPhotoModalVisible(false);
|
||||||
|
setFileList([]);
|
||||||
|
if (actionRef.current) {
|
||||||
|
actionRef.current.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProForm.Item
|
||||||
|
name="photo_crew"
|
||||||
|
label="Ảnh thuyền viên"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng chọn ảnh' }]}
|
||||||
|
>
|
||||||
|
<Upload
|
||||||
|
listType="picture-card"
|
||||||
|
fileList={fileList}
|
||||||
|
onChange={handleUploadChange}
|
||||||
|
beforeUpload={beforeUpload}
|
||||||
|
onPreview={async (file) => {
|
||||||
|
let src = file.url;
|
||||||
|
if (!src && file.originFileObj) {
|
||||||
|
src = await new Promise<string>((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsDataURL(file.originFileObj as Blob);
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const imgWindow = window.open('');
|
||||||
|
imgWindow?.document.write(`
|
||||||
|
<div style="display:flex;justify-content:center;align-items:center;min-height:100vh;background:#000;">
|
||||||
|
<img src="${src}" style="max-width:100%;max-height:100%;object-fit:contain;" />
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}}
|
||||||
|
maxCount={1}
|
||||||
|
accept="image/*"
|
||||||
|
>
|
||||||
|
{fileList.length < 1 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '250px',
|
||||||
|
height: '100px',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
border: '1px dashed #d9d9d9',
|
||||||
|
borderRadius: '6px',
|
||||||
|
backgroundColor: '#fafafa',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusOutlined style={{ fontSize: '28px', color: '#999' }} />
|
||||||
|
<div
|
||||||
|
style={{ marginTop: 12, fontSize: '16px', color: '#666' }}
|
||||||
|
>
|
||||||
|
Tải ảnh lên
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{ fontSize: '12px', color: '#999', marginTop: 4 }}
|
||||||
|
>
|
||||||
|
JPG, PNG ≤ 5MB
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Upload>
|
||||||
|
</ProForm.Item>
|
||||||
|
</ModalForm>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TripCrews;
|
||||||
58
src/pages/Slave/SGW/Trip/components/TripFishingGear.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { ProColumns, ProTable } from '@ant-design/pro-components';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface TripFishingGearTableProps {
|
||||||
|
fishingGears: SgwModel.FishingGear[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const TripFishingGearTable: React.FC<TripFishingGearTableProps> = ({
|
||||||
|
fishingGears,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const fishing_gears_columns: ProColumns<SgwModel.FishingGear>[] = [
|
||||||
|
{
|
||||||
|
title: (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
{intl.formatMessage({ id: 'trip.gear.name' })}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
dataIndex: 'name',
|
||||||
|
valueType: 'select',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
{intl.formatMessage({ id: 'trip.gear.quantity' })}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
dataIndex: 'number',
|
||||||
|
align: 'center',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProTable<SgwModel.FishingGear>
|
||||||
|
columns={fishing_gears_columns}
|
||||||
|
dataSource={fishingGears}
|
||||||
|
search={false}
|
||||||
|
pagination={{
|
||||||
|
pageSize: 5,
|
||||||
|
showSizeChanger: true,
|
||||||
|
showTotal: (total, range) =>
|
||||||
|
intl.formatMessage(
|
||||||
|
{ id: 'pagination.total' },
|
||||||
|
{ start: range[0], end: range[1], total },
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
options={false}
|
||||||
|
// bordered
|
||||||
|
size="middle"
|
||||||
|
scroll={{ x: '100%' }}
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TripFishingGearTable;
|
||||||
23
src/pages/Slave/SGW/Trip/components/UploadPhoto.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { PictureOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Tooltip } from 'antd';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface UploadPhotoProps {
|
||||||
|
text: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UploadPhoto: React.FC<UploadPhotoProps> = ({ text, onClick }) => {
|
||||||
|
return (
|
||||||
|
<Tooltip title={text}>
|
||||||
|
<Button
|
||||||
|
onClick={onClick}
|
||||||
|
shape="default"
|
||||||
|
size="small"
|
||||||
|
icon={<PictureOutlined />}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UploadPhoto;
|
||||||
@@ -1,5 +1,409 @@
|
|||||||
const TripPage = () => {
|
import { DeleteButton, EditButton } from '@/components/shared/Button';
|
||||||
return <div>TripPage</div>;
|
import { ProCard, ProTable } from '@ant-design/pro-components';
|
||||||
|
import type { ActionType, ProColumns } from '@ant-design/pro-table';
|
||||||
|
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
|
||||||
|
import { DatePicker, Divider } from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import utc from 'dayjs/plugin/utc';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import CreateTrip from './components/CreateTrip';
|
||||||
|
import EditTrip from './components/EditTrip';
|
||||||
|
import TripCrews from './components/TripCrews';
|
||||||
|
|
||||||
|
import { DATE_TIME_FORMAT, DEFAULT_PAGE_SIZE } from '@/constants';
|
||||||
|
import { apiQueryTrips } from '@/services/slave/sgw/TripController';
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
|
const TripList: React.FC = () => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const actionRef = useRef<ActionType>();
|
||||||
|
const { initialState } = useModel('@@initialState');
|
||||||
|
const { currentUserProfile } = initialState || {};
|
||||||
|
|
||||||
|
// State bổ sung cho các biến bị thiếu
|
||||||
|
const [currentRow, setCurrentRow] = useState<SgwModel.Trip>();
|
||||||
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
|
|
||||||
|
const columns: ProColumns<SgwModel.Trip>[] = [
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
id: 'pages.trips.name',
|
||||||
|
defaultMessage: 'Tên chuyến đi',
|
||||||
|
}),
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
id: 'pages.trips.ship_id',
|
||||||
|
defaultMessage: 'Mã tàu',
|
||||||
|
}),
|
||||||
|
dataIndex: 'ship_id',
|
||||||
|
key: 'ship_id',
|
||||||
|
hideInSearch: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
id: 'pages.trips.departure_time',
|
||||||
|
defaultMessage: 'Ngày đi',
|
||||||
|
}),
|
||||||
|
dataIndex: 'departure_time',
|
||||||
|
key: 'departure_time',
|
||||||
|
valueType: 'dateRange',
|
||||||
|
render: (_, record) => {
|
||||||
|
return record.departure_time
|
||||||
|
? dayjs(record.departure_time).format(DATE_TIME_FORMAT)
|
||||||
|
: '-';
|
||||||
|
},
|
||||||
|
renderFormItem: (_, config, form) => {
|
||||||
|
return (
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
width="50%"
|
||||||
|
presets={[
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'pages.date.yesterday',
|
||||||
|
defaultMessage: 'Yesterday',
|
||||||
|
}),
|
||||||
|
value: [dayjs().add(-1, 'd'), dayjs()],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'pages.date.lastweek',
|
||||||
|
defaultMessage: 'Last Week',
|
||||||
|
}),
|
||||||
|
value: [dayjs().add(-7, 'd'), dayjs()],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'pages.date.lastmonth',
|
||||||
|
defaultMessage: 'Last Month',
|
||||||
|
}),
|
||||||
|
value: [dayjs().add(-30, 'd'), dayjs()],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={(dates) => {
|
||||||
|
form.setFieldsValue({ departure_time: dates });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
id: 'pages.trips.arrival_time',
|
||||||
|
defaultMessage: 'Ngày về',
|
||||||
|
}),
|
||||||
|
dataIndex: 'arrival_time',
|
||||||
|
key: 'arrival_time',
|
||||||
|
valueType: 'dateTime',
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => {
|
||||||
|
return record.arrival_time
|
||||||
|
? dayjs(record.arrival_time).format(DATE_TIME_FORMAT)
|
||||||
|
: '-';
|
||||||
|
},
|
||||||
|
renderFormItem: (_, config, form) => {
|
||||||
|
return (
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
width="50%"
|
||||||
|
presets={[
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'pages.date.yesterday',
|
||||||
|
defaultMessage: 'Yesterday',
|
||||||
|
}),
|
||||||
|
value: [dayjs().add(-1, 'd'), dayjs()],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'pages.date.lastweek',
|
||||||
|
defaultMessage: 'Last Week',
|
||||||
|
}),
|
||||||
|
value: [dayjs().add(-7, 'd'), dayjs()],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'pages.date.lastmonth',
|
||||||
|
defaultMessage: 'Last Month',
|
||||||
|
}),
|
||||||
|
value: [dayjs().add(-30, 'd'), dayjs()],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onChange={(dates) => {
|
||||||
|
form.setFieldsValue({ arrival_time: dates });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
id: 'pages.trips.status',
|
||||||
|
defaultMessage: 'Trạng thái',
|
||||||
|
}),
|
||||||
|
dataIndex: 'trip_status',
|
||||||
|
key: 'trip_status',
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
0: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'pages.trips.status.created',
|
||||||
|
defaultMessage: 'Đã khởi tạo',
|
||||||
|
}),
|
||||||
|
status: 'Default',
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'pages.trips.status.pending_approval',
|
||||||
|
defaultMessage: 'Chờ duyệt',
|
||||||
|
}),
|
||||||
|
status: 'Processing',
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'pages.trips.status.approved',
|
||||||
|
defaultMessage: 'Đã duyệt',
|
||||||
|
}),
|
||||||
|
status: 'Success',
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'pages.trips.status.active',
|
||||||
|
defaultMessage: 'Đang hoạt động',
|
||||||
|
}),
|
||||||
|
status: 'Success',
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'pages.trips.status.completed',
|
||||||
|
defaultMessage: 'Hoàn thành',
|
||||||
|
}),
|
||||||
|
status: 'Success',
|
||||||
|
},
|
||||||
|
5: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'pages.trips.status.cancelled',
|
||||||
|
defaultMessage: 'Đã huỷ',
|
||||||
|
}),
|
||||||
|
status: 'Error',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: (
|
||||||
|
<FormattedMessage id="pages.things.option" defaultMessage="Operating" />
|
||||||
|
),
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{currentUserProfile?.metadata?.user_type === 'enduser' &&
|
||||||
|
![1, 2, 3, 4, 5].includes(record.trip_status) && (
|
||||||
|
<>
|
||||||
|
{/* Nút Edit */}
|
||||||
|
<EditButton
|
||||||
|
text="Edit"
|
||||||
|
onClick={() => {
|
||||||
|
setCurrentRow(record); // record là dòng hiện tại trong table
|
||||||
|
setEditOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal EditTrip */}
|
||||||
|
{editOpen && currentRow && (
|
||||||
|
<EditTrip
|
||||||
|
id={currentRow.id} // id chuyến đi
|
||||||
|
ship_id={currentRow.ship_id} // truyền ship_id
|
||||||
|
record={record} // truyền luôn cả record nếu cần
|
||||||
|
open={editOpen}
|
||||||
|
onOpenChange={setEditOpen}
|
||||||
|
onSuccess={() => {
|
||||||
|
setEditOpen(false);
|
||||||
|
setCurrentRow(undefined); // clear sau khi đóng
|
||||||
|
actionRef.current?.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider type="vertical" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* <LocationButton
|
||||||
|
text={intl.formatMessage({
|
||||||
|
id: "pages.things.location.text",
|
||||||
|
defaultMessage: "Location",
|
||||||
|
})}
|
||||||
|
onClick={() => {
|
||||||
|
setDataForMap(record);
|
||||||
|
console.log("Data for map:", record);
|
||||||
|
setCurrentRow(record);
|
||||||
|
handleLocationModalVisible(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Divider type="vertical" /> */}
|
||||||
|
|
||||||
|
<TripCrews
|
||||||
|
record={record}
|
||||||
|
onChange={(items) => console.log(items)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Divider type="vertical" />
|
||||||
|
|
||||||
|
{![0].includes(record.trip_status) &&
|
||||||
|
currentUserProfile?.metadata?.user_type === 'enduser' && (
|
||||||
|
<>
|
||||||
|
<DeleteButton
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'pages.trips.cancel.text',
|
||||||
|
defaultMessage: 'Cancel Trip',
|
||||||
|
})}
|
||||||
|
text="Hủy"
|
||||||
|
onOk={async () => {
|
||||||
|
// Thêm logic hủy chuyến đi
|
||||||
|
actionRef.current?.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProCard colSpan={{ xs: 24, sm: 24, md: 24, lg: 24, xl: 24 }}>
|
||||||
|
<ProTable<SgwModel.Trip>
|
||||||
|
columns={columns}
|
||||||
|
tableLayout="auto"
|
||||||
|
actionRef={actionRef}
|
||||||
|
rowKey="id"
|
||||||
|
search={{
|
||||||
|
layout: 'vertical',
|
||||||
|
defaultCollapsed: false,
|
||||||
|
}}
|
||||||
|
pagination={{
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE * 2,
|
||||||
|
showSizeChanger: false,
|
||||||
|
showTotal: (total, range) => `${range[0]}-${range[1]} trên ${total}`,
|
||||||
|
}}
|
||||||
|
request={async (params) => {
|
||||||
|
const {
|
||||||
|
current = 1,
|
||||||
|
pageSize = DEFAULT_PAGE_SIZE * 2,
|
||||||
|
name,
|
||||||
|
trip_status,
|
||||||
|
departure_time,
|
||||||
|
} = params;
|
||||||
|
const offset = current === 1 ? 0 : (current - 1) * pageSize;
|
||||||
|
|
||||||
|
const query: SgwModel.TripQueryParams = {
|
||||||
|
name: name || '',
|
||||||
|
order: '',
|
||||||
|
dir: 'desc',
|
||||||
|
offset,
|
||||||
|
limit: pageSize,
|
||||||
|
metadata: {
|
||||||
|
from: departure_time?.[0]
|
||||||
|
? dayjs(departure_time[0]).toISOString()
|
||||||
|
: '',
|
||||||
|
to: departure_time?.[1]
|
||||||
|
? dayjs(departure_time[1]).toISOString()
|
||||||
|
: '',
|
||||||
|
ship_name: '',
|
||||||
|
reg_number: '',
|
||||||
|
province_code: '',
|
||||||
|
owner_id: '',
|
||||||
|
ship_id: '',
|
||||||
|
status: trip_status ? String(trip_status) : '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TripPage;
|
try {
|
||||||
|
const response = await apiQueryTrips(query);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: response.trips || [],
|
||||||
|
total: response.total || 0,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Query trips error:', error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
toolbar={{
|
||||||
|
// title: (
|
||||||
|
// <div
|
||||||
|
// style={{ display: "flex", alignItems: "center", gap: 8 }}
|
||||||
|
// >
|
||||||
|
// {selectedThingId ? (
|
||||||
|
// <Tag
|
||||||
|
// style={{
|
||||||
|
// cursor: "pointer",
|
||||||
|
// fontSize: "12px",
|
||||||
|
// padding: "4px 8px",
|
||||||
|
// lineHeight: "20px",
|
||||||
|
// minWidth: "0",
|
||||||
|
// overflow: "hidden",
|
||||||
|
// textOverflow: "ellipsis",
|
||||||
|
// }}
|
||||||
|
// onClick={(e) => {
|
||||||
|
// e.stopPropagation();
|
||||||
|
// setOpenFormSearch(true); // Mở lại modal chọn tàu
|
||||||
|
// }}
|
||||||
|
// closeIcon
|
||||||
|
// onClose={(e) => {
|
||||||
|
// e.stopPropagation();
|
||||||
|
// setselectedThingId(null);
|
||||||
|
// setFormSearchData(null);
|
||||||
|
// if (actionRef.current) {
|
||||||
|
// actionRef.current.reload();
|
||||||
|
// }
|
||||||
|
// }}
|
||||||
|
// color="#87d068"
|
||||||
|
// >
|
||||||
|
// {
|
||||||
|
// thingsList.find((s) => s.id === selectedThingId)
|
||||||
|
// ?.metadata?.ship_name
|
||||||
|
// }
|
||||||
|
// </Tag>
|
||||||
|
// ) : (
|
||||||
|
// <Button onClick={() => setOpenFormSearch(true)}>
|
||||||
|
// Chọn tàu
|
||||||
|
// </Button>
|
||||||
|
// )}
|
||||||
|
// </div>
|
||||||
|
// ),
|
||||||
|
actions: [
|
||||||
|
<CreateTrip
|
||||||
|
key="create-trip"
|
||||||
|
// ship_name={
|
||||||
|
// thingsList.find((s) => s.id === selectedThingId)?.name
|
||||||
|
// }
|
||||||
|
// thing_id={selectedThingId}
|
||||||
|
// record={currentRow}
|
||||||
|
onSuccess={(value) => {
|
||||||
|
if (value) {
|
||||||
|
actionRef.current?.reload();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TripList;
|
||||||
|
|||||||
@@ -1,9 +1,284 @@
|
|||||||
import React from 'react';
|
import TagState from '@/components/shared/TagState';
|
||||||
|
import {
|
||||||
|
getBadgeConnection,
|
||||||
|
getBadgeStatus,
|
||||||
|
} from '@/components/shared/ThingShared';
|
||||||
|
import TreeGroup from '@/components/shared/TreeGroup';
|
||||||
|
import { DEFAULT_PAGE_SIZE } from '@/constants';
|
||||||
|
import { apiSearchThings } from '@/services/master/ThingController';
|
||||||
|
import {
|
||||||
|
ActionType,
|
||||||
|
ProCard,
|
||||||
|
ProColumns,
|
||||||
|
ProTable,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||||
|
import { Flex, Grid, theme, Typography } from 'antd';
|
||||||
|
import moment from 'moment';
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { TagStateCallbackPayload } from '../../SGW/Map/type';
|
||||||
|
const { Text } = Typography;
|
||||||
const SpoleHome: React.FC = () => {
|
const SpoleHome: React.FC = () => {
|
||||||
|
const { useBreakpoint } = Grid;
|
||||||
|
const intl = useIntl();
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const actionRef = useRef<ActionType | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [groupCheckedKeys, setGroupCheckedKeys] = useState<
|
||||||
|
string | string[] | null
|
||||||
|
>(null);
|
||||||
|
const [thing, setThing] = useState<
|
||||||
|
SpoleModel.SpoleThingsResponse | undefined
|
||||||
|
>(undefined);
|
||||||
|
const [stateQuery, setStateQuery] = useState<
|
||||||
|
TagStateCallbackPayload | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const columns: ProColumns<SpoleModel.SpoleThing>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
ellipsis: true,
|
||||||
|
title: (
|
||||||
|
<FormattedMessage id="master.devices.name" defaultMessage="Name" />
|
||||||
|
),
|
||||||
|
tip: intl.formatMessage({
|
||||||
|
id: 'master.devices.name.tip',
|
||||||
|
defaultMessage: 'The device name',
|
||||||
|
}),
|
||||||
|
dataIndex: 'name',
|
||||||
|
hideInSearch: true,
|
||||||
|
copyable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'connected',
|
||||||
|
hideInSearch: true,
|
||||||
|
ellipsis: true,
|
||||||
|
title: (
|
||||||
|
<FormattedMessage id="common.connect" defaultMessage="Connection" />
|
||||||
|
),
|
||||||
|
dataIndex: ['metadata', 'connected'],
|
||||||
|
filters: [
|
||||||
|
{ text: 'Connected', value: true },
|
||||||
|
{ text: 'Disconnected', value: false },
|
||||||
|
],
|
||||||
|
onFilter: (value: any, row) => row?.metadata?.connected === value,
|
||||||
|
render: (_, row) => {
|
||||||
|
const connectionDuration = row.metadata!.connected
|
||||||
|
? row.metadata!.uptime! * 1000
|
||||||
|
: (Math.round(new Date().getTime() / 1000) -
|
||||||
|
row.metadata!.updated_time!) *
|
||||||
|
1000;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap={5}>
|
||||||
|
{getBadgeConnection(row.metadata!.connected || false)}
|
||||||
|
<Text type={row.metadata?.connected ? undefined : 'secondary'}>
|
||||||
|
{connectionDuration > 0
|
||||||
|
? moment.duration(connectionDuration).humanize()
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
id: 'common.status',
|
||||||
|
defaultMessage: 'Status',
|
||||||
|
}),
|
||||||
|
dataIndex: ['metadata', 'state_level'],
|
||||||
|
key: 'state_level',
|
||||||
|
hideInSearch: true,
|
||||||
|
filters: true,
|
||||||
|
onFilter: true,
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
0: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'common.level.normal',
|
||||||
|
defaultMessage: 'Normal',
|
||||||
|
}),
|
||||||
|
status: 'Normal',
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'common.level.warning',
|
||||||
|
defaultMessage: 'Warning',
|
||||||
|
}),
|
||||||
|
status: 'Warning',
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'common.level.critical',
|
||||||
|
defaultMessage: 'Critical',
|
||||||
|
}),
|
||||||
|
status: 'Critical',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_, row) => {
|
||||||
|
const alarm = JSON.parse(row?.metadata?.alarm_list || '{}');
|
||||||
|
const text = alarm?.map((a: any) => a?.name).join(', ');
|
||||||
|
return (
|
||||||
|
<Flex gap={5}>
|
||||||
|
{getBadgeStatus(Number(row?.metadata?.state_level ?? -1))}
|
||||||
|
<Text type={row.metadata?.connected ? undefined : 'secondary'}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const handleTagStateChange = (payload: TagStateCallbackPayload) => {
|
||||||
|
setStateQuery(payload);
|
||||||
|
actionRef.current?.reload();
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Trang chủ (Spole)</h1>
|
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
||||||
|
<ProCard colSpan={{ xs: 24, sm: 24, md: 4, lg: 4, xl: 4 }}>
|
||||||
|
<TreeGroup
|
||||||
|
disable={isLoading}
|
||||||
|
multiple={true}
|
||||||
|
groupIds={groupCheckedKeys}
|
||||||
|
onSelected={(value: string | string[] | null) => {
|
||||||
|
setGroupCheckedKeys(value);
|
||||||
|
if (actionRef.current) {
|
||||||
|
actionRef.current.reload();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
<ProCard colSpan={{ xs: 24, sm: 24, md: 20, lg: 20, xl: 20 }}>
|
||||||
|
<ProTable<MasterModel.Thing>
|
||||||
|
bordered={true}
|
||||||
|
columns={columns}
|
||||||
|
tableLayout="auto"
|
||||||
|
actionRef={actionRef}
|
||||||
|
rowKey="id"
|
||||||
|
search={{
|
||||||
|
layout: 'vertical', // Hoặc 'vertical' để xếp dọc
|
||||||
|
defaultCollapsed: false, // Mặc định mở rộng
|
||||||
|
span: 12, // Chiếm 12 cột trên lưới (tổng 24 cột)
|
||||||
|
filterType: 'light', // Loại filter
|
||||||
|
labelWidth: 'auto', // Độ rộng nhãn
|
||||||
|
}}
|
||||||
|
size="large"
|
||||||
|
dateFormatter="string"
|
||||||
|
pagination={{
|
||||||
|
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['10', '15', '20'],
|
||||||
|
showTotal: (total, range) =>
|
||||||
|
`${range[0]}-${range[1]}
|
||||||
|
${intl.formatMessage({
|
||||||
|
id: 'common.paginations.of',
|
||||||
|
defaultMessage: 'of',
|
||||||
|
})}
|
||||||
|
${total} ${intl.formatMessage({
|
||||||
|
id: 'master.devices.table.pagination',
|
||||||
|
defaultMessage: 'devices',
|
||||||
|
})}`,
|
||||||
|
}}
|
||||||
|
request={async (params = {}) => {
|
||||||
|
const {
|
||||||
|
current = 1,
|
||||||
|
pageSize,
|
||||||
|
name,
|
||||||
|
external_id,
|
||||||
|
keyword,
|
||||||
|
} = params;
|
||||||
|
const size = pageSize || DEFAULT_PAGE_SIZE * 2;
|
||||||
|
const offset = current === 1 ? 0 : (current - 1) * size;
|
||||||
|
setIsLoading(true);
|
||||||
|
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;
|
||||||
|
let metadata: Partial<MasterModel.SearchThingMetadata> = {};
|
||||||
|
if (external_id) metadata.external_id = external_id;
|
||||||
|
|
||||||
|
if (metaStateQuery) metadata = { ...metadata, ...metaStateQuery };
|
||||||
|
// Add group filter if groups are selected
|
||||||
|
if (groupCheckedKeys && groupCheckedKeys.length > 0) {
|
||||||
|
const groupId = Array.isArray(groupCheckedKeys)
|
||||||
|
? groupCheckedKeys.join(',')
|
||||||
|
: groupCheckedKeys;
|
||||||
|
metadata.group_id = groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query: MasterModel.SearchThingPaginationBody = {
|
||||||
|
offset: offset,
|
||||||
|
limit: size,
|
||||||
|
order: 'name',
|
||||||
|
dir: 'asc',
|
||||||
|
};
|
||||||
|
if (keyword) query.name = keyword;
|
||||||
|
if (Object.keys(metadata).length > 0) query.metadata = metadata;
|
||||||
|
try {
|
||||||
|
const response = await apiSearchThings(query, 'spole');
|
||||||
|
setIsLoading(false);
|
||||||
|
setThing(response);
|
||||||
|
return {
|
||||||
|
data: response.things || [],
|
||||||
|
success: true,
|
||||||
|
total: response.total || 0,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
success: false,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
search: true,
|
||||||
|
setting: false,
|
||||||
|
density: false,
|
||||||
|
reload: true,
|
||||||
|
}}
|
||||||
|
toolbar={{
|
||||||
|
actions: [
|
||||||
|
<TagState
|
||||||
|
key={'device-state-tag'}
|
||||||
|
normalCount={thing?.metadata?.total_state_level_0 || 0}
|
||||||
|
warningCount={thing?.metadata?.total_state_level_1 || 0}
|
||||||
|
criticalCount={thing?.metadata?.total_state_level_2 || 0}
|
||||||
|
sosCount={undefined}
|
||||||
|
disconnectedCount={
|
||||||
|
(thing?.metadata?.total_thing ?? 0) -
|
||||||
|
(thing?.metadata?.total_connected ?? 0) || 0
|
||||||
|
}
|
||||||
|
onTagPress={handleTagStateChange}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
</ProCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ export async function apiLogin(body: MasterModel.LoginRequestBody) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiQueryProfile() {
|
export async function apiQueryProfile() {
|
||||||
return request<MasterModel.ProfileResponse>(API_PATH_GET_PROFILE);
|
return request<MasterModel.UserResponse>(API_PATH_GET_PROFILE);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiUpdateProfile(
|
export async function apiUpdateProfile(
|
||||||
body: Partial<MasterModel.ProfileMetadata>,
|
body: Partial<MasterModel.UserMetadata>,
|
||||||
) {
|
) {
|
||||||
return request<MasterModel.ProfileResponse>(API_USERS, {
|
return request<MasterModel.UserResponse>(API_USERS, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
data: {
|
data: {
|
||||||
metadata: body,
|
metadata: body,
|
||||||
|
|||||||
@@ -5,6 +5,19 @@ import {
|
|||||||
} from '@/constants/api';
|
} from '@/constants/api';
|
||||||
import { request } from '@umijs/max';
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
|
export async function apiSearchThings(
|
||||||
|
body: MasterModel.SearchPaginationBody,
|
||||||
|
domain: 'spole',
|
||||||
|
): Promise<SpoleModel.SpoleThingsResponse>;
|
||||||
|
export async function apiSearchThings(
|
||||||
|
body: MasterModel.SearchPaginationBody,
|
||||||
|
domain: 'sgw',
|
||||||
|
): Promise<SgwModel.SgwThingsResponse>;
|
||||||
|
export async function apiSearchThings(
|
||||||
|
body: MasterModel.SearchPaginationBody,
|
||||||
|
domain?: 'gms',
|
||||||
|
): Promise<GmsModel.GmsThingsResponse>;
|
||||||
|
|
||||||
export async function apiSearchThings(
|
export async function apiSearchThings(
|
||||||
body: MasterModel.SearchPaginationBody,
|
body: MasterModel.SearchPaginationBody,
|
||||||
domain: string = process.env.DOMAIN_ENV || 'gms',
|
domain: string = process.env.DOMAIN_ENV || 'gms',
|
||||||
|
|||||||
@@ -4,19 +4,21 @@ import { request } from '@umijs/max';
|
|||||||
export async function apiQueryUsers(
|
export async function apiQueryUsers(
|
||||||
params: MasterModel.SearchUserPaginationBody,
|
params: MasterModel.SearchUserPaginationBody,
|
||||||
) {
|
) {
|
||||||
return request<MasterModel.UserResponse>(API_USERS, {
|
return request<MasterModel.UserListResponse>(API_USERS, {
|
||||||
params: params,
|
params: params,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiQueryUserById(userId: string) {
|
export async function apiQueryUserById(userId: string) {
|
||||||
return request<MasterModel.ProfileResponse>(`${API_USERS}/${userId}`);
|
return request<MasterModel.UserResponse>(`${API_USERS}/${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiQueryUsersByGroup(
|
export async function apiQueryUsersByGroup(
|
||||||
group_id: string,
|
group_id: string,
|
||||||
): Promise<MasterModel.UserResponse> {
|
): Promise<MasterModel.UserListResponse> {
|
||||||
return request<MasterModel.UserResponse>(`${API_USERS_BY_GROUP}/${group_id}`);
|
return request<MasterModel.UserListResponse>(
|
||||||
|
`${API_USERS_BY_GROUP}/${group_id}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiCreateUsers(body: MasterModel.CreateUserBodyRequest) {
|
export async function apiCreateUsers(body: MasterModel.CreateUserBodyRequest) {
|
||||||
|
|||||||
244
src/services/master/typings.d.ts
vendored
@@ -8,254 +8,12 @@ declare namespace MasterModel {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
dir?: 'asc' | 'desc';
|
dir?: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
interface SearchThingPaginationBody extends SearchPaginationBody {
|
|
||||||
order?: string;
|
|
||||||
metadata?: ThingMetadata;
|
|
||||||
}
|
|
||||||
interface ThingMetadata {
|
|
||||||
group_id?: string;
|
|
||||||
external_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchAlarmPaginationBody extends SearchPaginationBody {
|
interface PaginationReponse {
|
||||||
order?: 'name' | undefined;
|
|
||||||
thing_name?: string;
|
|
||||||
thing_id?: string;
|
|
||||||
level?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchUserPaginationBody extends SearchPaginationBody {
|
|
||||||
order?: 'email' | 'name' | undefined;
|
|
||||||
email?: string;
|
|
||||||
metadata?: Partial<ProfileMetadata>;
|
|
||||||
}
|
|
||||||
interface SearchLogPaginationBody extends SearchPaginationBody {
|
|
||||||
from?: number;
|
|
||||||
to?: number;
|
|
||||||
publisher?: string;
|
|
||||||
subtopic?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth
|
|
||||||
interface LoginRequestBody {
|
|
||||||
guid: string;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoginResponse {
|
|
||||||
token?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChangePasswordRequestBody {
|
|
||||||
old_password: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProfileResponse {
|
|
||||||
id?: string;
|
|
||||||
email?: string;
|
|
||||||
metadata?: ProfileMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProfileMetadata {
|
|
||||||
frontend_thing_id?: string;
|
|
||||||
frontend_thing_key?: string;
|
|
||||||
full_name?: string;
|
|
||||||
phone_number?: string;
|
|
||||||
telegram?: string;
|
|
||||||
user_type?: 'admin' | 'enduser' | 'sysadmin' | 'users';
|
|
||||||
}
|
|
||||||
|
|
||||||
// User
|
|
||||||
interface CreateUserMetadata extends ProfileMetadata {
|
|
||||||
group_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreateUserBodyRequest extends Partial<ProfileResponse> {
|
|
||||||
password: string;
|
|
||||||
full_name?: string;
|
|
||||||
metadata?: CreateUserMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserResponse {
|
|
||||||
total?: number;
|
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
|
||||||
users: ProfileResponse[];
|
|
||||||
}
|
|
||||||
interface AlarmsResponse {
|
|
||||||
total?: number;
|
|
||||||
limit?: number;
|
|
||||||
order?: string;
|
|
||||||
dir?: string;
|
|
||||||
alarms?: Alarm[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfirmAlarmRequest {
|
|
||||||
id?: string;
|
|
||||||
description?: string;
|
|
||||||
thing_id?: string;
|
|
||||||
time?: number;
|
|
||||||
}
|
|
||||||
// Alarm
|
|
||||||
interface Alarm {
|
|
||||||
name?: string;
|
|
||||||
time?: number;
|
|
||||||
level?: number;
|
|
||||||
id?: string;
|
|
||||||
confirmed?: boolean;
|
|
||||||
confirmed_email?: string;
|
|
||||||
confirmed_time?: number;
|
|
||||||
confirmed_desc?: string;
|
|
||||||
thing_id?: string;
|
|
||||||
thing_name?: string;
|
|
||||||
thing_type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thing
|
|
||||||
interface ThingMetadata {
|
|
||||||
address?: string;
|
|
||||||
alarm_list?: string;
|
|
||||||
cfg_channel_id?: string;
|
|
||||||
connected?: boolean;
|
|
||||||
ctrl_channel_id?: string;
|
|
||||||
data_channel_id?: string;
|
|
||||||
enduser?: string;
|
|
||||||
external_id?: string;
|
|
||||||
group_id?: string;
|
|
||||||
req_channel_id?: string;
|
|
||||||
state?: string;
|
|
||||||
state_level?: number;
|
|
||||||
state_updated_time?: number;
|
|
||||||
type?: string;
|
|
||||||
updated_time?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThingsResponse<T extends ThingMetadata = ThingMetadata> {
|
|
||||||
total?: number;
|
total?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
order?: string;
|
order?: string;
|
||||||
direction?: string;
|
direction?: string;
|
||||||
metadata?: ThingsResponseMetadata;
|
|
||||||
things?: Thing<T>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThingsResponseMetadata {
|
|
||||||
total_connected?: number;
|
|
||||||
total_filter?: number;
|
|
||||||
total_sos?: number;
|
|
||||||
total_state_level_0?: number;
|
|
||||||
total_state_level_1?: number;
|
|
||||||
total_state_level_2?: number;
|
|
||||||
total_thing?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Thing<T extends ThingMetadata = ThingMetadata> {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
key?: string;
|
|
||||||
metadata?: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thing Policy
|
|
||||||
interface ThingPolicyResponse {
|
|
||||||
total?: number;
|
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
|
||||||
order?: string;
|
|
||||||
direction?: string;
|
|
||||||
metadata?: null;
|
|
||||||
things?: ThingPolicy[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThingPolicy {
|
|
||||||
policies?: Policy[];
|
|
||||||
thing_id?: string;
|
|
||||||
thing_name?: string;
|
|
||||||
external_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Policy = 'read' | 'delete' | 'write';
|
|
||||||
|
|
||||||
// Group
|
|
||||||
|
|
||||||
interface GroupBodyRequest {
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
parent_id?: string;
|
|
||||||
description?: string;
|
|
||||||
metadata?: GroupMetadata;
|
|
||||||
}
|
|
||||||
interface AddGroupBodyResponse extends Partial<GroupBodyRequest> {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupMetadata {
|
|
||||||
code?: string;
|
|
||||||
short_name?: string;
|
|
||||||
has_thing?: boolean;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupResponse {
|
|
||||||
total?: number;
|
|
||||||
level?: number;
|
|
||||||
name?: string;
|
|
||||||
groups?: GroupNode[];
|
|
||||||
}
|
|
||||||
interface GroupNode {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
owner_id: string;
|
|
||||||
description: string;
|
|
||||||
metadata: GroupMetadata;
|
|
||||||
level: number;
|
|
||||||
path: string;
|
|
||||||
parent_id?: string;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
children?: GroupNode[]; // Đệ quy: mỗi node có thể có children là mảng GroupNode
|
|
||||||
[key: string]: any; // Nếu có thêm trường động
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupQueryParams {
|
|
||||||
level?: number;
|
|
||||||
tree?: boolean;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log
|
|
||||||
|
|
||||||
type LogTypeRequest = 'user_logs' | undefined;
|
|
||||||
|
|
||||||
interface LogResponse {
|
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
|
||||||
publisher?: string;
|
|
||||||
from?: number;
|
|
||||||
to?: number;
|
|
||||||
format?: string;
|
|
||||||
total?: number;
|
|
||||||
messages?: Message[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Message {
|
|
||||||
channel?: string;
|
|
||||||
subtopic?: string;
|
|
||||||
publisher?: string;
|
|
||||||
protocol?: string;
|
|
||||||
name?: string;
|
|
||||||
time?: number;
|
|
||||||
string_value?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// User
|
|
||||||
|
|
||||||
interface AssignMemberRequest {
|
|
||||||
group_id: string;
|
|
||||||
type: 'users' | 'admin' | 'things' | undefined;
|
|
||||||
members: string[];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/services/master/typings/alarm.d.ts
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
declare namespace MasterModel {
|
||||||
|
interface SearchAlarmPaginationBody extends SearchPaginationBody {
|
||||||
|
order?: 'name' | undefined;
|
||||||
|
thing_name?: string;
|
||||||
|
thing_id?: string;
|
||||||
|
level?: number;
|
||||||
|
}
|
||||||
|
interface AlarmsResponse {
|
||||||
|
total?: number;
|
||||||
|
limit?: number;
|
||||||
|
order?: string;
|
||||||
|
dir?: string;
|
||||||
|
alarms?: Alarm[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmAlarmRequest {
|
||||||
|
id?: string;
|
||||||
|
description?: string;
|
||||||
|
thing_id?: string;
|
||||||
|
time?: number;
|
||||||
|
}
|
||||||
|
// Alarm
|
||||||
|
interface Alarm {
|
||||||
|
name?: string;
|
||||||
|
time?: number;
|
||||||
|
level?: number;
|
||||||
|
id?: string;
|
||||||
|
confirmed?: boolean;
|
||||||
|
confirmed_email?: string;
|
||||||
|
confirmed_time?: number;
|
||||||
|
confirmed_desc?: string;
|
||||||
|
thing_id?: string;
|
||||||
|
thing_name?: string;
|
||||||
|
thing_type?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/services/master/typings/auth.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
declare namespace MasterModel {
|
||||||
|
interface LoginRequestBody {
|
||||||
|
guid: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/services/master/typings/group.d.ts
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
declare namespace MasterModel {
|
||||||
|
interface GroupBodyRequest {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
parent_id?: string;
|
||||||
|
description?: string;
|
||||||
|
metadata?: GroupMetadata;
|
||||||
|
}
|
||||||
|
interface AddGroupBodyResponse extends Partial<GroupBodyRequest> {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupMetadata {
|
||||||
|
code?: string;
|
||||||
|
short_name?: string;
|
||||||
|
has_thing?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupResponse {
|
||||||
|
total?: number;
|
||||||
|
level?: number;
|
||||||
|
name?: string;
|
||||||
|
groups?: GroupNode[];
|
||||||
|
}
|
||||||
|
interface GroupNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
owner_id: string;
|
||||||
|
description: string;
|
||||||
|
metadata: GroupMetadata;
|
||||||
|
level: number;
|
||||||
|
path: string;
|
||||||
|
parent_id?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
children?: GroupNode[]; // Đệ quy: mỗi node có thể có children là mảng GroupNode
|
||||||
|
[key: string]: any; // Nếu có thêm trường động
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupQueryParams {
|
||||||
|
level?: number;
|
||||||
|
tree?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssignMemberRequest {
|
||||||
|
group_id: string;
|
||||||
|
type: 'users' | 'admin' | 'things' | undefined;
|
||||||
|
members: string[];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/services/master/typings/log.d.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
declare namespace MasterModel {
|
||||||
|
interface SearchLogPaginationBody extends SearchPaginationBody {
|
||||||
|
from?: number;
|
||||||
|
to?: number;
|
||||||
|
publisher?: string;
|
||||||
|
subtopic?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogTypeRequest = 'user_logs' | undefined;
|
||||||
|
|
||||||
|
interface LogResponse {
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
publisher?: string;
|
||||||
|
from?: number;
|
||||||
|
to?: number;
|
||||||
|
format?: string;
|
||||||
|
total?: number;
|
||||||
|
messages?: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
channel?: string;
|
||||||
|
subtopic?: string;
|
||||||
|
publisher?: string;
|
||||||
|
protocol?: string;
|
||||||
|
name?: string;
|
||||||
|
time?: number;
|
||||||
|
string_value?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/services/master/typings/thing.d.ts
vendored
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
declare namespace MasterModel {
|
||||||
|
interface SearchThingPaginationBody<T = SearchThingMetadata>
|
||||||
|
extends SearchPaginationBody {
|
||||||
|
order?: string;
|
||||||
|
metadata?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchThingMetadata {
|
||||||
|
external_id?: string;
|
||||||
|
state_level?: string; // vd: "normal,warning,critical,sos"
|
||||||
|
/** kết nối */
|
||||||
|
connected?: boolean;
|
||||||
|
group_id?: string; // "id1,id2,id3"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thing
|
||||||
|
interface ThingReponseMetadata {
|
||||||
|
address?: string;
|
||||||
|
alarm_list?: string;
|
||||||
|
cfg_channel_id?: string;
|
||||||
|
connected?: boolean;
|
||||||
|
ctrl_channel_id?: string;
|
||||||
|
data_channel_id?: string;
|
||||||
|
enduser?: string;
|
||||||
|
external_id?: string;
|
||||||
|
group_id?: string;
|
||||||
|
req_channel_id?: string;
|
||||||
|
state?: string;
|
||||||
|
state_level?: number;
|
||||||
|
state_updated_time?: number;
|
||||||
|
type?: string;
|
||||||
|
updated_time?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThingsResponse<
|
||||||
|
T extends ThingReponseMetadata = ThingReponseMetadata,
|
||||||
|
> extends PaginationReponse {
|
||||||
|
metadata?: ThingsResponseMetadata;
|
||||||
|
things?: Thing<T>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThingsResponseMetadata {
|
||||||
|
total_connected?: number;
|
||||||
|
total_filter?: number;
|
||||||
|
total_sos?: number;
|
||||||
|
total_state_level_0?: number;
|
||||||
|
total_state_level_1?: number;
|
||||||
|
total_state_level_2?: number;
|
||||||
|
total_thing?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Thing<T extends ThingReponseMetadata = ThingReponseMetadata> {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
key?: string;
|
||||||
|
metadata?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thing Policy
|
||||||
|
interface ThingPolicyResponse extends PaginationReponse {
|
||||||
|
metadata?: null;
|
||||||
|
things?: ThingPolicy[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThingPolicy {
|
||||||
|
policies?: Policy[];
|
||||||
|
thing_id?: string;
|
||||||
|
thing_name?: string;
|
||||||
|
external_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Policy = 'read' | 'delete' | 'write';
|
||||||
|
}
|
||||||
44
src/services/master/typings/user.d.ts
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
declare namespace MasterModel {
|
||||||
|
interface SearchUserPaginationBody extends SearchPaginationBody {
|
||||||
|
order?: 'email' | 'name' | undefined;
|
||||||
|
email?: string;
|
||||||
|
metadata?: Partial<UserMetadata>;
|
||||||
|
}
|
||||||
|
interface ChangePasswordRequestBody {
|
||||||
|
old_password: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserResponse {
|
||||||
|
id?: string;
|
||||||
|
email?: string;
|
||||||
|
metadata?: UserMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserMetadata {
|
||||||
|
frontend_thing_id?: string;
|
||||||
|
frontend_thing_key?: string;
|
||||||
|
full_name?: string;
|
||||||
|
phone_number?: string;
|
||||||
|
telegram?: string;
|
||||||
|
user_type?: 'admin' | 'enduser' | 'sysadmin' | 'users';
|
||||||
|
}
|
||||||
|
|
||||||
|
// User
|
||||||
|
interface CreateUserMetadata extends UserMetadata {
|
||||||
|
group_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateUserBodyRequest extends Partial<UserResponse> {
|
||||||
|
password: string;
|
||||||
|
full_name?: string;
|
||||||
|
metadata?: CreateUserMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserListResponse {
|
||||||
|
total?: number;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
users: UserResponse[];
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/services/slave/sgw/MapService.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import shipAlarmIcon from '../../../assets/ship_alarm.png';
|
||||||
|
import shipAlarmFishingIcon from '../../../assets/ship_alarm_fishing.png';
|
||||||
|
import shipOnlineIcon from '../../../assets/ship_online.png';
|
||||||
|
import shipOnlineFishingIcon from '../../../assets/ship_online_fishing.png';
|
||||||
|
import shipUndefineIcon from '../../../assets/ship_undefine.png';
|
||||||
|
import shipWarningIcon from '../../../assets/ship_warning.png';
|
||||||
|
import shipWarningFishingIcon from '../../../assets/ship_warning_fishing.png';
|
||||||
|
import shipSosIcon from '../../../assets/sos_icon.png';
|
||||||
|
|
||||||
|
export const getShipIcon = (type: number, isFishing: boolean) => {
|
||||||
|
if (type === 1 && !isFishing) {
|
||||||
|
return shipWarningIcon;
|
||||||
|
} else if (type === 2 && !isFishing) {
|
||||||
|
return shipAlarmIcon;
|
||||||
|
} else if (type === 0 && !isFishing) {
|
||||||
|
return shipOnlineIcon;
|
||||||
|
} else if (type === 1 && isFishing) {
|
||||||
|
return shipWarningFishingIcon;
|
||||||
|
} else if (type === 2 && isFishing) {
|
||||||
|
return shipAlarmFishingIcon;
|
||||||
|
} else if (type === 0 && isFishing) {
|
||||||
|
return shipOnlineFishingIcon;
|
||||||
|
} else if (type === 3) {
|
||||||
|
return shipSosIcon;
|
||||||
|
} else {
|
||||||
|
return shipUndefineIcon;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const convertToDMS = (value: number, isLat: boolean): string => {
|
||||||
|
const deg = Math.floor(Math.abs(value));
|
||||||
|
const minFloat = (Math.abs(value) - deg) * 60;
|
||||||
|
const min = Math.floor(minFloat);
|
||||||
|
const sec = (minFloat - min) * 60;
|
||||||
|
|
||||||
|
const direction = value >= 0 ? (isLat ? 'N' : 'E') : isLat ? 'S' : 'W';
|
||||||
|
|
||||||
|
return `${deg}°${min}'${sec.toFixed(2)}"${direction}`;
|
||||||
|
};
|
||||||
38
src/services/slave/sgw/PhotoController.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { SGW_ROUTE_PHOTO } from '@/constants/slave/sgw/routes';
|
||||||
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get photo from server
|
||||||
|
* @param type Type of photo ('ship' or 'people')
|
||||||
|
* @param id ID of the entity
|
||||||
|
* @returns Photo as ArrayBuffer
|
||||||
|
*/
|
||||||
|
export async function apiGetPhoto(
|
||||||
|
type: SgwModel.PhotoGetParams['type'],
|
||||||
|
id: string,
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
return request<ArrayBuffer>(`${SGW_ROUTE_PHOTO}/${type}/${id}/main`, {
|
||||||
|
method: 'GET',
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload photo to server
|
||||||
|
* @param type Type of photo ('ship' or 'people')
|
||||||
|
* @param id ID of the entity
|
||||||
|
* @param file File to upload
|
||||||
|
*/
|
||||||
|
export async function apiUploadPhoto(
|
||||||
|
type: SgwModel.PhotoUploadParams['type'],
|
||||||
|
id: string,
|
||||||
|
file: File,
|
||||||
|
): Promise<void> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
return request<void>(`${SGW_ROUTE_PHOTO}/${type}/${id}/main`, {
|
||||||
|
method: 'POST',
|
||||||
|
data: formData,
|
||||||
|
});
|
||||||
|
}
|
||||||
136
src/services/slave/sgw/ShipController.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import {
|
||||||
|
SGW_ROUTE_PORTS,
|
||||||
|
SGW_ROUTE_SHIP_GROUPS,
|
||||||
|
SGW_ROUTE_SHIP_TYPES,
|
||||||
|
SGW_ROUTE_SHIPS,
|
||||||
|
} from '@/constants/slave/sgw/routes';
|
||||||
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
|
// ========== Ship CRUD Operations ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new ship
|
||||||
|
*/
|
||||||
|
export async function apiAddShip(
|
||||||
|
params: SgwModel.ShipCreateParams,
|
||||||
|
): Promise<SgwModel.ShipDetail> {
|
||||||
|
return request<SgwModel.ShipDetail>(SGW_ROUTE_SHIPS, {
|
||||||
|
method: 'POST',
|
||||||
|
data: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing ship
|
||||||
|
*/
|
||||||
|
export async function apiUpdateShip(
|
||||||
|
id: string,
|
||||||
|
update: SgwModel.ShipUpdateParams,
|
||||||
|
): Promise<SgwModel.ShipDetail> {
|
||||||
|
return request<SgwModel.ShipDetail>(`${SGW_ROUTE_SHIPS}/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: update,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a ship by ID
|
||||||
|
*/
|
||||||
|
export async function apiDeleteShip(id: string): Promise<void> {
|
||||||
|
return request<void>(`${SGW_ROUTE_SHIPS}/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query ships with filters
|
||||||
|
*/
|
||||||
|
export async function apiQueryShips(
|
||||||
|
params?: SgwModel.ShipQueryParams,
|
||||||
|
): Promise<SgwModel.ShipQueryResponse> {
|
||||||
|
return request<SgwModel.ShipQueryResponse>(SGW_ROUTE_SHIPS, {
|
||||||
|
params: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get ship detail by thing ID
|
||||||
|
*/
|
||||||
|
export async function apiViewShip(
|
||||||
|
thingId: string,
|
||||||
|
): Promise<SgwModel.ShipDetail> {
|
||||||
|
return request<SgwModel.ShipDetail>(`${SGW_ROUTE_SHIPS}/${thingId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all ship types
|
||||||
|
*/
|
||||||
|
export async function apiGetShipTypes(): Promise<SgwModel.ShipType[]> {
|
||||||
|
return request<SgwModel.ShipType[]>(SGW_ROUTE_SHIP_TYPES);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Ship Group Operations ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a new ship group
|
||||||
|
*/
|
||||||
|
export async function apiAddShipGroup(
|
||||||
|
params: SgwModel.ShipGroupCreateParams,
|
||||||
|
): Promise<SgwModel.ShipGroup> {
|
||||||
|
return request<SgwModel.ShipGroup>(SGW_ROUTE_SHIP_GROUPS, {
|
||||||
|
method: 'POST',
|
||||||
|
data: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all ship groups
|
||||||
|
*/
|
||||||
|
export async function apiGetShipGroups(): Promise<
|
||||||
|
SgwModel.GroupShipResponse[]
|
||||||
|
> {
|
||||||
|
return request<SgwModel.GroupShipResponse[]>(SGW_ROUTE_SHIP_GROUPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List all ship groups (alternative method)
|
||||||
|
*/
|
||||||
|
export async function apiListGroupShips(): Promise<SgwModel.ShipGroup[]> {
|
||||||
|
return request<SgwModel.ShipGroup[]>(SGW_ROUTE_SHIP_GROUPS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a ship group
|
||||||
|
*/
|
||||||
|
export async function apiUpdateShipGroup(
|
||||||
|
id: string,
|
||||||
|
update: SgwModel.ShipGroupUpdateParams,
|
||||||
|
): Promise<SgwModel.ShipGroup> {
|
||||||
|
return request<SgwModel.ShipGroup>(`${SGW_ROUTE_SHIP_GROUPS}/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: update,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a ship group
|
||||||
|
*/
|
||||||
|
export async function apiDeleteShipGroup(id: string): Promise<void> {
|
||||||
|
return request<void>(`${SGW_ROUTE_SHIP_GROUPS}/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Port Operations ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query ports with filters
|
||||||
|
*/
|
||||||
|
export async function apiQueryPorts(
|
||||||
|
params?: SgwModel.PortQueryParams,
|
||||||
|
): Promise<SgwModel.PortQueryResponse> {
|
||||||
|
return request<SgwModel.PortQueryResponse>(SGW_ROUTE_PORTS, {
|
||||||
|
method: 'POST',
|
||||||
|
data: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
224
src/services/slave/sgw/TripController.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import {
|
||||||
|
SGW_ROUTE_CREW,
|
||||||
|
SGW_ROUTE_GET_FISH,
|
||||||
|
SGW_ROUTE_HAUL_HANDLE,
|
||||||
|
SGW_ROUTE_TRIP_CREW,
|
||||||
|
SGW_ROUTE_TRIPS,
|
||||||
|
SGW_ROUTE_TRIPS_BY_ID,
|
||||||
|
SGW_ROUTE_TRIPS_CREWS,
|
||||||
|
SGW_ROUTE_TRIPS_LAST,
|
||||||
|
SGW_ROUTE_TRIPS_LIST,
|
||||||
|
SGW_ROUTE_UPDATE_FISHING_LOGS,
|
||||||
|
SGW_ROUTE_UPDATE_TRIP_STATUS,
|
||||||
|
} from '@/constants/slave/sgw/routes';
|
||||||
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update trip state/status
|
||||||
|
* @param body Trip state update request
|
||||||
|
*/
|
||||||
|
export async function apiUpdateTripState(
|
||||||
|
body: SgwModel.TripUpdateStateRequest,
|
||||||
|
) {
|
||||||
|
return request(SGW_ROUTE_UPDATE_TRIP_STATUS, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new haul (fishing log)
|
||||||
|
* @param body New fishing log request
|
||||||
|
*/
|
||||||
|
export async function apiStartNewHaul(body: SgwModel.NewFishingLogRequest) {
|
||||||
|
return request(SGW_ROUTE_HAUL_HANDLE, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of fish species
|
||||||
|
*/
|
||||||
|
export async function apiGetFishSpecies() {
|
||||||
|
return request<SgwModel.FishSpeciesResponse[]>(SGW_ROUTE_GET_FISH);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update fishing logs information
|
||||||
|
* @param body Fishing log data
|
||||||
|
*/
|
||||||
|
export async function apiUpdateFishingLogs(body: SgwModel.FishingLog) {
|
||||||
|
return request(SGW_ROUTE_UPDATE_FISHING_LOGS, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new trip for a thing (device)
|
||||||
|
* @param thing_id Thing/Device ID
|
||||||
|
* @param params Trip creation parameters
|
||||||
|
*/
|
||||||
|
export async function apiCreateTrip(
|
||||||
|
thing_id: string,
|
||||||
|
params: SgwModel.TripCreateParams,
|
||||||
|
): Promise<SgwModel.Trip> {
|
||||||
|
return request<SgwModel.Trip>(`${SGW_ROUTE_TRIPS}/${thing_id}`, {
|
||||||
|
method: 'POST',
|
||||||
|
data: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query trips list with filters
|
||||||
|
* @param params Query parameters
|
||||||
|
*/
|
||||||
|
export async function apiQueryTrips(
|
||||||
|
params: SgwModel.TripQueryParams,
|
||||||
|
): Promise<SgwModel.TripQueryResponse> {
|
||||||
|
return request<SgwModel.TripQueryResponse>(SGW_ROUTE_TRIPS_LIST, {
|
||||||
|
method: 'POST',
|
||||||
|
data: params,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a trip by ID
|
||||||
|
* @param trip_id Trip ID
|
||||||
|
* @param update Trip update parameters
|
||||||
|
*/
|
||||||
|
export async function apiUpdateTrip(
|
||||||
|
trip_id: string,
|
||||||
|
update: SgwModel.TripUpdateParams,
|
||||||
|
): Promise<SgwModel.Trip> {
|
||||||
|
return request<SgwModel.Trip>(`${SGW_ROUTE_TRIPS}/${trip_id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: update,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete trip(s)
|
||||||
|
* @param data Trip deletion parameters (array of trip IDs)
|
||||||
|
*/
|
||||||
|
export async function apiDeleteTrip(
|
||||||
|
data: SgwModel.TripDeleteParams,
|
||||||
|
): Promise<void> {
|
||||||
|
return request(SGW_ROUTE_TRIPS, {
|
||||||
|
method: 'DELETE',
|
||||||
|
data: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query last trip for a thing (device)
|
||||||
|
* @param thing_id Thing/Device ID
|
||||||
|
*/
|
||||||
|
export async function apiQueryLastTrips(
|
||||||
|
thing_id: string,
|
||||||
|
): Promise<SgwModel.Trip> {
|
||||||
|
return request<SgwModel.Trip>(`${SGW_ROUTE_TRIPS_LAST}/${thing_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get trip by ID
|
||||||
|
* @param trip_id Trip ID
|
||||||
|
*/
|
||||||
|
export async function apiGetTripById(trip_id: string): Promise<SgwModel.Trip> {
|
||||||
|
return request<SgwModel.Trip>(`${SGW_ROUTE_TRIPS_BY_ID}/${trip_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================
|
||||||
|
Crew Management APIs
|
||||||
|
=========================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new crew member
|
||||||
|
* @param data Crew creation parameters
|
||||||
|
*/
|
||||||
|
export async function apiCreateCrew(
|
||||||
|
data: SgwModel.CrewCreateParams,
|
||||||
|
): Promise<SgwModel.TripCrewPerson> {
|
||||||
|
return request<SgwModel.TripCrewPerson>(SGW_ROUTE_CREW, {
|
||||||
|
method: 'POST',
|
||||||
|
data: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get crew member by ID
|
||||||
|
* @param crew_id Crew member ID
|
||||||
|
*/
|
||||||
|
export async function apiGetCrew(
|
||||||
|
crew_id: string,
|
||||||
|
): Promise<SgwModel.TripCrewPerson> {
|
||||||
|
return request<SgwModel.TripCrewPerson>(`${SGW_ROUTE_TRIPS}/crew/${crew_id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update crew member information
|
||||||
|
* @param crew_id Crew member ID
|
||||||
|
* @param update Crew update parameters
|
||||||
|
*/
|
||||||
|
export async function apiUpdateCrew(
|
||||||
|
crew_id: string,
|
||||||
|
update: SgwModel.CrewUpdateParams,
|
||||||
|
): Promise<SgwModel.TripCrewPerson> {
|
||||||
|
return request<SgwModel.TripCrewPerson>(`${SGW_ROUTE_CREW}/${crew_id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: update,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add crew member to a trip
|
||||||
|
* @param data Trip crew creation parameters
|
||||||
|
*/
|
||||||
|
export async function apiAddTripCrew(
|
||||||
|
data: SgwModel.TripCrewCreateParams,
|
||||||
|
): Promise<void> {
|
||||||
|
return request(SGW_ROUTE_TRIP_CREW, {
|
||||||
|
method: 'POST',
|
||||||
|
data: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all crew members for a trip
|
||||||
|
* @param trip_id Trip ID
|
||||||
|
*/
|
||||||
|
export async function apiGetTripCrew(
|
||||||
|
trip_id: string,
|
||||||
|
): Promise<SgwModel.TripCrewQueryResponse> {
|
||||||
|
return request<SgwModel.TripCrewQueryResponse>(
|
||||||
|
`${SGW_ROUTE_TRIPS_CREWS}/${trip_id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update trip crew information
|
||||||
|
* @param update Trip crew update parameters
|
||||||
|
*/
|
||||||
|
export async function apiUpdateTripCrew(
|
||||||
|
update: SgwModel.TripCrewUpdateParams,
|
||||||
|
): Promise<void> {
|
||||||
|
return request(SGW_ROUTE_TRIP_CREW, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: update,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove crew member from a trip
|
||||||
|
* @param trip_id Trip ID
|
||||||
|
* @param crew_id Crew member ID
|
||||||
|
*/
|
||||||
|
export async function apiDeleteTripCrew(
|
||||||
|
trip_id: string,
|
||||||
|
crew_id: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return request(`${SGW_ROUTE_TRIP_CREW}/${trip_id}/${crew_id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
65
src/services/slave/sgw/ZoneController.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
SGW_ROUTE_BANZONES,
|
||||||
|
SGW_ROUTE_BANZONES_LIST,
|
||||||
|
} from '@/constants/slave/sgw/routes';
|
||||||
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all banzones with pagination and search
|
||||||
|
* @param body Search and pagination parameters
|
||||||
|
*/
|
||||||
|
export async function apiGetAllBanzones(
|
||||||
|
body: MasterModel.SearchPaginationBody,
|
||||||
|
) {
|
||||||
|
return request<SgwModel.ZoneResponse>(SGW_ROUTE_BANZONES_LIST, {
|
||||||
|
method: 'POST',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a banzone
|
||||||
|
* @param id Banzone ID
|
||||||
|
* @param groupID Group ID
|
||||||
|
*/
|
||||||
|
export async function apiRemoveBanzone(id: string, groupID: string) {
|
||||||
|
return request(`${SGW_ROUTE_BANZONES}/${id}/${groupID}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get banzone by ID
|
||||||
|
* @param zoneId Banzone ID
|
||||||
|
*/
|
||||||
|
export async function apiGetZoneById(
|
||||||
|
zoneId: string,
|
||||||
|
): Promise<SgwModel.Banzone> {
|
||||||
|
return request<SgwModel.Banzone>(`${SGW_ROUTE_BANZONES}/${zoneId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new banzone
|
||||||
|
* @param body Banzone data
|
||||||
|
*/
|
||||||
|
export async function apiCreateBanzone(body: SgwModel.ZoneBasicInfo) {
|
||||||
|
return request(SGW_ROUTE_BANZONES, {
|
||||||
|
method: 'POST',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update banzone by ID
|
||||||
|
* @param id Banzone ID
|
||||||
|
* @param body Updated banzone data
|
||||||
|
*/
|
||||||
|
export async function apiUpdateBanzone(
|
||||||
|
id: string,
|
||||||
|
body: SgwModel.ZoneBasicInfo,
|
||||||
|
) {
|
||||||
|
return request(`${SGW_ROUTE_BANZONES}/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
3
src/services/slave/sgw/sgw.typing.d.ts
vendored
@@ -2,7 +2,8 @@
|
|||||||
// 该文件由 OneAPI 自动生成,请勿手动修改!
|
// 该文件由 OneAPI 自动生成,请勿手动修改!
|
||||||
|
|
||||||
declare namespace SgwModel {
|
declare namespace SgwModel {
|
||||||
interface ThingMedata extends MasterModel.ThingMetadata {
|
// Thing
|
||||||
|
interface ThingMedata extends MasterModel.ThingReponseMetadata {
|
||||||
gps?: string;
|
gps?: string;
|
||||||
gps_time?: string;
|
gps_time?: string;
|
||||||
ship_group_id?: string;
|
ship_group_id?: string;
|
||||||
|
|||||||
17
src/services/slave/sgw/typings/crew.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
interface CrewBaseInfo {
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
birth_date?: string;
|
||||||
|
address?: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
interface CrewCreateParams extends CrewBaseInfo {
|
||||||
|
personal_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CrewUpdateParams extends Partial<CrewBaseInfo> {
|
||||||
|
personal_id?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/services/slave/sgw/typings/fish.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
interface FishSpeciesResponse {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
scientific_name?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/services/slave/sgw/typings/fishing_log.d.ts
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
interface FishingLogInfo {
|
||||||
|
fish_species_id?: number;
|
||||||
|
fish_name?: string;
|
||||||
|
catch_number?: number;
|
||||||
|
catch_unit?: string;
|
||||||
|
fish_size?: number;
|
||||||
|
fish_rarity?: number;
|
||||||
|
fish_condition?: string;
|
||||||
|
gear_usage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FishingLog {
|
||||||
|
fishing_log_id?: string;
|
||||||
|
trip_id: string;
|
||||||
|
start_at: Date;
|
||||||
|
end_at: Date;
|
||||||
|
start_lat: number;
|
||||||
|
start_lon: number;
|
||||||
|
haul_lat: number;
|
||||||
|
haul_lon: number;
|
||||||
|
status: number;
|
||||||
|
weather_description: string;
|
||||||
|
info?: FishingLogInfo[];
|
||||||
|
sync: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewFishingLogRequest {
|
||||||
|
trip_id: string;
|
||||||
|
start_at: Date;
|
||||||
|
start_lat: number;
|
||||||
|
start_lon: number;
|
||||||
|
weather_description: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/services/slave/sgw/typings/gear.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
interface FishingGear {
|
||||||
|
name: string;
|
||||||
|
number: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/services/slave/sgw/typings/photo.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
// interface PhotoBasicInfo {
|
||||||
|
// type: 'ship' | 'people';
|
||||||
|
// id: string;
|
||||||
|
// }
|
||||||
|
|
||||||
|
interface PhotoGetParams {
|
||||||
|
type: 'ship' | 'people';
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhotoUploadParams {
|
||||||
|
type: 'ship' | 'people';
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/services/slave/sgw/typings/port.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
interface Port {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
classification: string;
|
||||||
|
position_point: string;
|
||||||
|
has_origin_confirm: boolean;
|
||||||
|
province_code: string;
|
||||||
|
updated_at: string;
|
||||||
|
is_deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PortQueryParams extends MasterModel.SearchPaginationBody {
|
||||||
|
order?: string;
|
||||||
|
metadata?: {
|
||||||
|
province_code?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PortQueryResponse extends MasterModel.PaginationReponse {
|
||||||
|
ports: Port[];
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/services/slave/sgw/typings/ship.d.ts
vendored
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
interface ShipBaseInfo {
|
||||||
|
name?: string;
|
||||||
|
reg_number?: string;
|
||||||
|
ship_type?: number;
|
||||||
|
ship_length?: number;
|
||||||
|
ship_power?: number;
|
||||||
|
home_port?: number;
|
||||||
|
fishing_license_number?: string;
|
||||||
|
fishing_license_expiry_date?: string;
|
||||||
|
ship_group_id?: string | null; // Lưu ý: Update có thể cần null để gỡ nhóm
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShipType {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
interface ShipMetadata {
|
||||||
|
crew_count?: number;
|
||||||
|
home_port?: string;
|
||||||
|
home_port_point?: string;
|
||||||
|
ship_type?: string;
|
||||||
|
trip_arrival_port?: string;
|
||||||
|
trip_arrival_port_point?: string;
|
||||||
|
trip_arrival_time?: Date;
|
||||||
|
trip_depart_port?: string;
|
||||||
|
trip_depart_port_point?: string;
|
||||||
|
trip_departure_time?: Date;
|
||||||
|
trip_id?: string;
|
||||||
|
trip_name?: string;
|
||||||
|
trip_state?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShipDetail extends ShipBaseInfo {
|
||||||
|
id?: string;
|
||||||
|
thing_id?: string;
|
||||||
|
owner_id?: string;
|
||||||
|
imo_number?: string;
|
||||||
|
mmsi_number?: string;
|
||||||
|
province_code?: string;
|
||||||
|
created_at?: Date | string;
|
||||||
|
updated_at?: Date | string;
|
||||||
|
metadata?: ShipMetadata;
|
||||||
|
}
|
||||||
|
interface ShipCreateParams extends ShipBaseInfo {
|
||||||
|
thing_id?: string;
|
||||||
|
}
|
||||||
|
interface ShipUpdateParams {
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
interface ShipQueryParams extends MasterModel.SearchPaginationBody {
|
||||||
|
order?: string;
|
||||||
|
registration_number?: string;
|
||||||
|
ship_type?: number;
|
||||||
|
ship_group_id?: string;
|
||||||
|
thing_id?: string;
|
||||||
|
}
|
||||||
|
interface ShipQueryResponse extends MasterModel.PaginationReponse {
|
||||||
|
ships: ShipDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ship Group
|
||||||
|
|
||||||
|
interface ShipGroupBaseInfo {
|
||||||
|
name: string;
|
||||||
|
owner_id?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShipGroup extends ShipGroupBaseInfo {
|
||||||
|
id: string;
|
||||||
|
created_at?: Date | string;
|
||||||
|
updated_at?: Date | string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupShipResponse extends ShipGroupBaseInfo {
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShipGroupCreateParams extends ShipGroupBaseInfo {
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShipGroupUpdateParams extends ShipGroupBaseInfo {
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/services/slave/sgw/typings/trip.d.ts
vendored
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
interface TripBasicInfo {
|
||||||
|
name: string;
|
||||||
|
departure_time: string;
|
||||||
|
arrival_time: string;
|
||||||
|
trip_status: number;
|
||||||
|
ship_name: string;
|
||||||
|
departure_port_id: number;
|
||||||
|
arrival_port_id: number;
|
||||||
|
fishing_ground_codes: number[];
|
||||||
|
trip_cost: TripCost[];
|
||||||
|
notes: string | null;
|
||||||
|
fishing_gears?: FishingGear[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Trip extends TripBasicInfo {
|
||||||
|
id: string;
|
||||||
|
ship_id: string;
|
||||||
|
ship_length: number;
|
||||||
|
vms_id: string;
|
||||||
|
crews?: TripCrews[];
|
||||||
|
total_catch_weight: number | null;
|
||||||
|
total_species_caught: number | null;
|
||||||
|
trip_status: number;
|
||||||
|
approved_by: string;
|
||||||
|
fishing_logs: FishingLog[] | null;
|
||||||
|
sync: boolean;
|
||||||
|
}
|
||||||
|
interface TripQueryParams extends MasterModel.SearchPaginationBody {
|
||||||
|
order?: string;
|
||||||
|
metadata?: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
ship_name?: string;
|
||||||
|
reg_number?: string;
|
||||||
|
province_code?: string;
|
||||||
|
owner_id?: string;
|
||||||
|
ship_id?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripQueryResponse extends MasterModel.PaginationReponse {
|
||||||
|
trips: Trip[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripUpdateParams extends Partial<TripBasicInfo> {
|
||||||
|
crews?: TripCrews[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripDeleteParams {
|
||||||
|
trip_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripUpdateStateRequest {
|
||||||
|
status: number;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripCreateParams extends Partial<TripBasicInfo> {
|
||||||
|
crews?: TripCrews[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripCost {
|
||||||
|
type: string;
|
||||||
|
unit: string;
|
||||||
|
amount: string;
|
||||||
|
total_cost: string;
|
||||||
|
cost_per_unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trip Crews
|
||||||
|
|
||||||
|
interface TripCrewPerson {
|
||||||
|
personal_id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
birth_date: Date;
|
||||||
|
note: string;
|
||||||
|
address: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripCrews {
|
||||||
|
role: string;
|
||||||
|
joined_at: Date;
|
||||||
|
left_at: Date | null;
|
||||||
|
note: string | null;
|
||||||
|
Person: TripCrewPerson;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripCrewBasicInfo {
|
||||||
|
personal_id: string;
|
||||||
|
role: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripCrewCreateParams extends Partial<TripCrewBasicInfo> {
|
||||||
|
trip_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripCrewUpdateParams extends Partial<TripCrewBasicInfo> {
|
||||||
|
trip_id: string;
|
||||||
|
}
|
||||||
|
interface TripCrewQueryResponse {
|
||||||
|
trip_crews: TripCrews[];
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/services/slave/sgw/typings/ws.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
declare namespace WsTypes {
|
||||||
|
interface WsThingResponse {
|
||||||
|
thing_id?: string;
|
||||||
|
key?: string;
|
||||||
|
data?: string;
|
||||||
|
time?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||