feat(sgw): Add new services and utilities for ship, trip, and photo management

This commit is contained in:
Lê Tuấn Anh
2026-01-23 15:18:02 +07:00
parent e5b388505a
commit 1a06328c77
75 changed files with 9749 additions and 8 deletions

View File

@@ -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',

185
ZONE_MIGRATION.md Normal file
View File

@@ -0,0 +1,185 @@
# ✅ Zone (Banzone) API Migration - Complete
## Migration Banzone/Zone API vào SGW Module
### Files Created
1. **ZoneController.ts** - `src/services/slave/sgw/ZoneController.ts`
```typescript
✅ apiGetAllBanzones(body) - Get all banzones with pagination
✅ apiRemoveBanzone(id, groupID) - Remove a banzone
✅ apiGetZoneById(zoneId) - Get banzone by ID
✅ apiCreateBanzone(body) - Create new banzone
✅ apiUpdateBanzone(id, body) - Update banzone
```
2. **Type Definitions** - Added to `src/services/slave/sgw/sgw.typing.d.ts`
```typescript
✅ Banzone
✅ Condition
✅ Geom
✅ ZoneResponse
✅ ZoneBodyRequest
```
3. **Route Constants** - Added to `src/constants/slave/sgw/routes.ts`
```typescript
✅ SGW_ROUTE_BANZONES = '/api/sgw/banzones'
✅ SGW_ROUTE_BANZONES_LIST = '/api/sgw/banzones/list'
```
### Files Updated
- ✅ `src/pages/Slave/SGW/Map/components/ShipDetail.tsx`
- Updated import: `@/services/controller/ZoneController` → `@/services/slave/sgw/ZoneController`
- Updated types: `API.Thing` → `SgwModel.SgwThing`
- Updated types: `API.UserResponse` → `MasterModel.UserResponse`
- Updated types: `API.Geom` → `SgwModel.Geom`
### Migration Changes
**Before:**
```typescript
import { apiGetZoneById } from '@/services/controller/ZoneController';
thing: API.Thing
const zone_geom: API.Geom = ...
```
**After:**
```typescript
import { apiGetZoneById } from '@/services/slave/sgw/ZoneController';
thing: SgwModel.SgwThing
const zone_geom: SgwModel.Geom = ...
```
### Type Definitions
```typescript
declare namespace SgwModel {
interface Banzone {
id?: string;
name?: string;
province_code?: string;
type?: number;
conditions?: Condition[];
description?: string;
geometry?: string;
enabled?: boolean;
created_at?: Date;
updated_at?: Date;
}
interface Condition {
max?: number;
min?: number;
type?: 'length_limit' | 'month_range' | 'date_range';
to?: number;
from?: number;
}
interface Geom {
geom_type?: number;
geom_poly?: string;
geom_lines?: string;
geom_point?: string;
geom_radius?: number;
}
interface ZoneResponse {
total?: number;
offset?: number;
limit?: number;
banzones?: Banzone[];
}
interface ZoneBodyRequest {
name?: string;
province_code?: string;
type?: number;
conditions?: Condition[];
description?: string;
geometry?: string;
enabled?: boolean;
}
}
```
### API Usage Examples
**Get Zone by ID:**
```typescript
import { apiGetZoneById } from '@/services/slave/sgw/ZoneController';
const zone = await apiGetZoneById('zone-123');
const geometry: SgwModel.Geom = JSON.parse(zone.geometry || '{}');
```
**Create Banzone:**
```typescript
import { apiCreateBanzone } from '@/services/slave/sgw/ZoneController';
const zoneData: SgwModel.ZoneBodyRequest = {
name: 'Vùng cấm mùa sinh sản',
province_code: 'QN',
type: 1,
enabled: true,
conditions: [
{
type: 'month_range',
from: 4,
to: 8,
},
],
geometry: JSON.stringify({
geom_type: 1,
geom_poly: 'POLYGON(...)',
}),
};
await apiCreateBanzone(zoneData);
```
**Get All Banzones:**
```typescript
import { apiGetAllBanzones } from '@/services/slave/sgw/ZoneController';
const response = await apiGetAllBanzones({
offset: 0,
limit: 20,
order: 'name',
dir: 'asc',
});
```
## Status: ✅ Complete
- ✅ 5 API functions migrated
- ✅ 5 type definitions added
- ✅ 2 route constants added
- ✅ 1 file updated (ShipDetail.tsx)
- ✅ 0 compilation errors
## Total SGW Migration Progress
| Module | APIs | Types | Status |
| --------- | ------ | ------- | ------ |
| Ship | 12 | 15+ | ✅ |
| Trip | 17 | 21+ | ✅ |
| Photo | 2 | 2 | ✅ |
| Zone | 5 | 5 | ✅ |
| **TOTAL** | **36** | **43+** | ✅ |
---
**Migration Date:** January 23, 2026
**Status:** ✅ Complete
**Ready for Testing:** YES

BIN
src/assets/alarm_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
src/assets/exclamation.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
src/assets/marker.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
src/assets/ship_alarm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
src/assets/ship_alarm_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
src/assets/ship_online.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
src/assets/ship_warning.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
src/assets/sos_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

BIN
src/assets/warning_icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View 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>
);
};

View File

@@ -0,0 +1,191 @@
import {
AlertOutlined,
CheckOutlined,
DisconnectOutlined,
ExclamationOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { useIntl } from '@umijs/max';
import { Flex, Tag, Tooltip } from 'antd';
import { useState } from 'react';
import { TagStateCallbackPayload } from '../../pages/Slave/SGW/Map/type';
import style from './index.less';
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 intl = useIntl();
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,
isDisconected: newStates.disconnected,
});
}
};
// const tagStyles = {
// sos: activeStates.sos
// ? { background: '#ff4d4f', color: '#fff', borderColor: '#ff4d4f' }
// : { background: '#fff1f0', color: '#cf1322', borderColor: '#ffa39e' },
// normal: activeStates.normal
// ? { background: '#52c41a', color: '#fff', borderColor: '#52c41a' }
// : { background: '#f6ffed', color: '#389e0d', borderColor: '#b7eb8f' },
// warning: activeStates.warning
// ? {
// background: '#faad14',
// color: '#fff',
// borderColor: '#faad14',
// hoverColor: '#ffe58f',
// }
// : {
// background: '#fffbe6',
// color: '#d48806',
// borderColor: '#ffe58f',
// hoverColor: '#faad14',
// },
// critical: activeStates.critical
// ? { background: '#d4380d', color: '#fff', borderColor: '#d4380d' }
// : { background: '#fff2e8', color: '#d4380d', borderColor: '#ffd8bf' },
// disconnected: activeStates.disconnected
// ? { background: '#8c8c8c', color: '#fff', borderColor: '#8c8c8c' }
// : { background: '#f5f5f5', color: '#8c8c8c', borderColor: '#d9d9d9' },
// };
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: 'thing.status.sos',
defaultMessage: 'SOS',
})}
>
<Tag.CheckableTag
className={activeStates.sos ? style.criticalActive : style.critical}
// style={tagStyles.sos}
icon={<AlertOutlined />}
checked={activeStates.sos}
onChange={() => handleTagClick('sos')}
>
{`${sosCount}`}
</Tag.CheckableTag>
</Tooltip>
)}
{/* {normalCount > 0 && ( */}
<Tooltip
title={intl.formatMessage({
id: 'thing.status.normal',
defaultMessage: 'Normal',
})}
>
<Tag.CheckableTag
className={activeStates.normal ? style.normalActive : style.normal}
// style={tagStyles.normal}
icon={<CheckOutlined />}
checked={activeStates.normal}
onChange={() => handleTagClick('normal')}
>
{`${normalCount}`}
</Tag.CheckableTag>
</Tooltip>
{/* {warningCount > 0 && ( */}
<Tooltip
title={intl.formatMessage({
id: 'thing.status.warning',
defaultMessage: 'Warning',
})}
>
<Tag.CheckableTag
className={activeStates.warning ? style.warningActive : style.warning}
icon={<WarningOutlined />}
// style={tagStyles.warning}
checked={activeStates.warning}
onChange={() => handleTagClick('warning')}
>
{`${warningCount}`}
</Tag.CheckableTag>
</Tooltip>
{/* {criticalCount > 0 && ( */}
<Tooltip
title={intl.formatMessage({
id: 'thing.status.critical',
defaultMessage: 'Critical',
})}
>
<Tag.CheckableTag
className={
activeStates.critical ? style.criticalActive : style.critical
}
icon={<ExclamationOutlined />}
// style={tagStyles.critical}
checked={activeStates.critical}
onChange={() => handleTagClick('critical')}
>
{`${criticalCount}`}
</Tag.CheckableTag>
</Tooltip>
{/* {disconnectedCount > 0 && ( */}
<Tooltip
title={intl.formatMessage({
id: 'thing.status.disconnected',
defaultMessage: 'Disconnected',
})}
>
<Tag.CheckableTag
className={
activeStates.disconnected ? style.offlineActive : style.offline
}
icon={<DisconnectOutlined />}
// style={tagStyles.disconnected}
checked={activeStates.disconnected}
onChange={() => handleTagClick('disconnected')}
>
{`${disconnectedCount}`}
</Tag.CheckableTag>
</Tooltip>
</Flex>
);
};
export default TagState;

View File

@@ -0,0 +1,22 @@
import {
STATUS_DANGEROUS,
STATUS_NORMAL,
STATUS_SOS,
STATUS_WARNING,
} from '@/constants';
import { Badge } from 'antd';
export const getBadgeStatus = (status: number) => {
switch (status) {
case STATUS_NORMAL:
return <Badge status="success" />;
case STATUS_WARNING:
return <Badge status="warning" />;
case STATUS_DANGEROUS:
return <Badge status="error" />;
case STATUS_SOS:
return <Badge status="error" />;
default:
return <Badge status="default" />;
}
};

View 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;
}

View File

@@ -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;

View File

@@ -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',
}

View File

@@ -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';

View File

@@ -0,0 +1 @@
export const SHIP_SOS_WS_URL = 'wss://sgw.gms.vn/thingscache';

View File

@@ -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',
}; };

View File

@@ -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',
}; };

View 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,
};
}

View 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 };
}

View 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,
};
}

File diff suppressed because it is too large Load Diff

View 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;

View 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;

View 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;

View 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;

View 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;

View 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.ProfileResponse
| 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>
);
};

View 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 }),
}),
});
}
};

View 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;

View 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>
</>
);
};

View 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;

View 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> 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;

View 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;

View 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';
}
};

View 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;
}

View File

@@ -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?.isDisconected)
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);
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;

View 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;
isDisconected: 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.ProfileResponse | 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 };

View 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;

View 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.ThingMetadata, '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;

View 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;

View 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>
);
};

View 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;

View 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;

View 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;

View 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;

View File

@@ -0,0 +1,542 @@
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;
}
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<SgwModel.TripFormValues>
>({});
const formRef = useRef<ProFormInstance<SgwModel.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: SgwModel.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={handleSubmit}
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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View File

@@ -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) : '',
},
};
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 TripPage; export default TripList;

View 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}`;
};

View 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,
});
}

View 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,
});
}

View 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',
});
}

View 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.ZoneBodyRequest) {
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.ZoneBodyRequest,
) {
return request(`${SGW_ROUTE_BANZONES}/${id}`, {
method: 'PUT',
data: body,
});
}

View File

@@ -2,6 +2,7 @@
// 该文件由 OneAPI 自动生成,请勿手动修改! // 该文件由 OneAPI 自动生成,请勿手动修改!
declare namespace SgwModel { declare namespace SgwModel {
// Thing
interface ThingMedata extends MasterModel.ThingMetadata { interface ThingMedata extends MasterModel.ThingMetadata {
gps?: string; gps?: string;
gps_time?: string; gps_time?: string;
@@ -22,4 +23,442 @@ declare namespace SgwModel {
type SgwThingsResponse = MasterModel.ThingsResponse<SgwModel.ThingMedata>; type SgwThingsResponse = MasterModel.ThingsResponse<SgwModel.ThingMedata>;
type SgwThing = MasterModel.Thing<SgwModel.ThingMedata>; type SgwThing = MasterModel.Thing<SgwModel.ThingMedata>;
// Ship
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 ShipType {
id?: number;
name?: string;
description?: string;
}
interface ShipDetail {
id?: string;
thing_id?: string;
owner_id?: string;
name?: string;
ship_type?: number;
home_port?: number;
ship_length?: number;
ship_power?: number;
reg_number?: string;
imo_number?: string;
mmsi_number?: string;
fishing_license_number?: string;
fishing_license_expiry_date?: Date | string;
province_code?: string;
ship_group_id?: string | null;
created_at?: Date | string;
updated_at?: Date | string;
metadata?: ShipMetadata;
}
interface ShipCreateParams {
thing_id?: string;
name?: string;
reg_number?: string;
ship_type?: number;
ship_length?: number;
ship_power?: number;
ship_group_id?: string;
home_port?: number;
fishing_license_number?: string;
fishing_license_expiry_date?: string;
}
interface ShipUpdateParams {
name?: string;
reg_number?: string;
ship_type?: number;
ship_group_id?: string | null;
ship_length?: number;
ship_power?: number;
home_port?: number;
fishing_license_number?: string;
fishing_license_expiry_date?: string;
metadata?: Record<string, unknown>;
}
interface ShipQueryParams {
offset?: number;
limit?: number;
order?: string;
dir?: 'asc' | 'desc';
name?: string;
registration_number?: string;
ship_type?: number;
ship_group_id?: string;
thing_id?: string;
}
interface ShipQueryResponse {
ships: ShipDetail[];
total: number;
offset: number;
limit: number;
}
interface ShipsResponse<T extends ShipMetadata = ShipMetadata> {
total?: number;
offset?: number;
limit?: number;
order?: string;
direction?: string;
Ship?: Ship<T>[];
}
// Ship Group
interface ShipGroup {
id: string;
name: string;
owner_id?: string;
description?: string;
created_at?: Date | string;
updated_at?: Date | string;
metadata?: Record<string, unknown>;
}
interface GroupShipResponse {
id?: string;
name?: string;
owner_id?: string;
description?: string;
}
interface ShipGroupCreateParams {
name: string;
description?: string;
metadata?: Record<string, unknown>;
}
interface ShipGroupUpdateParams {
name?: string;
description?: string;
metadata?: Record<string, unknown>;
}
// Port
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 {
name?: string;
order?: string;
dir?: 'asc' | 'desc';
limit?: number;
offset?: number;
metadata?: {
province_code?: string;
};
}
interface PortQueryResponse {
total: number;
offset: number;
limit: number;
ports: Port[];
}
// Trip Management
interface FishingGear {
name: string;
number: string;
}
interface TripCost {
type: string;
unit: string;
amount: string;
total_cost: string;
cost_per_unit: string;
}
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 CrewCreateParams {
personal_id: string;
name: string;
phone?: string;
email?: string;
birth_date?: string;
address?: string;
note?: string;
}
interface CrewUpdateParams {
name?: string;
phone?: string;
email?: string;
birth_date?: string;
address?: string;
note?: string;
}
interface TripCrewCreateParams {
trip_id: string;
personal_id: string;
role: string;
note?: string;
}
interface TripCrewUpdateParams {
trip_id: string;
personal_id: string;
role?: string;
note?: string;
}
interface TripCrewQueryResponse {
trip_crews: TripCrews[];
total?: number;
}
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;
}
interface FishSpecies {
id: number;
name: string;
scientific_name?: string;
description?: string;
}
interface FishSpeciesResponse extends FishSpecies {}
interface Trip {
id: string;
ship_id: string;
ship_length: number;
vms_id: string;
name: string;
fishing_gears: FishingGear[];
crews?: TripCrews[];
departure_time: string;
departure_port_id: number;
arrival_time: string;
arrival_port_id: number;
fishing_ground_codes: number[];
total_catch_weight: number | null;
total_species_caught: number | null;
trip_cost: TripCost[];
trip_status: number;
approved_by: string;
notes: string | null;
fishing_logs: FishingLog[] | null;
sync: boolean;
}
interface TripUpdateStateRequest {
status: number;
note?: string;
}
interface TripCreateParams {
name: string;
departure_time: string;
departure_port_id: number;
arrival_time?: string;
arrival_port_id?: number;
fishing_ground_codes?: number[];
fishing_gears?: FishingGear[];
crews?: TripCrews[];
trip_cost?: TripCost[];
notes?: 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?: FishingGear[];
trip_cost?: TripCost[];
ship_id?: string;
}
interface TripQueryParams {
name?: string;
order?: string;
dir?: 'asc' | 'desc';
offset?: number;
limit?: number;
metadata?: {
from?: string;
to?: string;
ship_name?: string;
reg_number?: string;
province_code?: string;
owner_id?: string;
ship_id?: string;
status?: string;
};
}
interface TripQueryResponse {
trips: Trip[];
total: number;
offset: number;
limit: number;
}
interface TripUpdateParams {
name?: string;
departure_time?: string;
departure_port_id?: number;
arrival_time?: string;
arrival_port_id?: number;
fishing_ground_codes?: number[];
fishing_gears?: FishingGear[];
crews?: TripCrews[];
trip_cost?: TripCost[];
trip_status?: number;
notes?: string;
}
interface TripDeleteParams {
trip_ids: string[];
}
// Photo Management
interface PhotoGetParams {
type: 'ship' | 'people';
id: string;
}
interface PhotoUploadParams {
type: 'ship' | 'people';
id: string;
file: File;
}
// Banzone Management
interface Banzone {
id?: string;
name?: string;
province_code?: string;
type?: number;
conditions?: Condition[];
description?: string;
geometry?: string;
enabled?: boolean;
created_at?: Date;
updated_at?: Date;
}
interface Condition {
max?: number;
min?: number;
type?: 'length_limit' | 'month_range' | 'date_range';
to?: number;
from?: number;
}
interface Geom {
geom_type?: number;
geom_poly?: string;
geom_lines?: string;
geom_point?: string;
geom_radius?: number;
}
interface ZoneResponse {
total?: number;
offset?: number;
limit?: number;
banzones?: Banzone[];
}
interface ZoneBodyRequest {
name?: string;
province_code?: string;
type?: number;
conditions?: Condition[];
description?: string;
geometry?: string;
enabled?: boolean;
}
}
declare namespace WsTypes {
interface WsThingResponse {
thing_id?: string;
key?: string;
data?: string;
time?: number;
}
} }

View File

@@ -0,0 +1,113 @@
import { ZoneData } from '@/pages/Slave/SGW/Map/type';
export const convertWKTPointToLatLng = (
wktString: string,
): [number, number] | null => {
if (
!wktString ||
typeof wktString !== 'string' ||
!wktString.startsWith('POINT')
) {
return null;
}
const matched = wktString.match(/POINT\s*\(([-\d.]+)\s+([-\d.]+)\)/);
if (!matched) return null;
const lng = parseFloat(matched[1]);
const lat = parseFloat(matched[2]);
return [lng, lat]; // [longitude, latitude]
};
export const convertWKTLineStringToLatLngArray = (wktString: string) => {
if (
!wktString ||
typeof wktString !== 'string' ||
!wktString.startsWith('LINESTRING')
) {
return [];
}
const matched = wktString.match(/LINESTRING\s*\((.*)\)/);
if (!matched) return [];
const coordinates = matched[1].split(',').map((coordStr) => {
const [x, y] = coordStr.trim().split(' ').map(Number);
return [x, y]; // [lng, lat]
});
return coordinates;
};
export const convertWKTtoLatLngString = (wktString: string) => {
if (
!wktString ||
typeof wktString !== 'string' ||
!wktString.startsWith('MULTIPOLYGON')
) {
return [];
}
const matched = wktString.match(/MULTIPOLYGON\s*\(\(\((.*)\)\)\)/);
if (!matched) return [];
const polygons = matched[1]
.split(')),((') // chia các polygon
.map((polygonStr) =>
polygonStr
.trim()
.split(',')
.map((coordStr) => {
const [x, y] = coordStr.trim().split(' ').map(Number);
return [x, y];
}),
);
return polygons;
};
export const getBanzoneNameByType = (type: number) => {
switch (type) {
case 1:
return 'Cấm đánh bắt';
case 2:
return 'Cấm di chuyển';
case 3:
return 'Vùng an toàn';
default:
return 'Chưa có';
}
};
const PI = 3.1416;
/**
* Hàm tính bán kính từ diện tích (hecta) để hiển thị trên bản đồ
* @param areaInHa - Diện tích tính bằng hecta
* @returns Bán kính tính bằng mét
*/
export const getCircleRadius = (areaInHa: number) => {
const areaInSquareMeters = areaInHa * 10000;
const radius = Math.sqrt(areaInSquareMeters / PI);
return Math.round(radius); // Trả về số nguyên (mét)
};
/**
* Hàm tính diện tích (hecta) từ bán kính (mét)
* @param radiusInMeters - Bán kính tính bằng mét
* @returns Diện tích tính bằng hecta (ha), làm tròn số nguyên
*/
export const getAreaFromRadius = (radiusInMeters: number) => {
const areaInSquareMeters = PI * Math.pow(radiusInMeters, 2);
const areaInHa = areaInSquareMeters / 10000;
return Math.round(areaInHa); // Trả về số nguyên (ha)
};
export const getAlarmTypeName = (type: number): ZoneData['type'] => {
switch (type) {
case 1:
return 'warning';
case 2:
return 'alarm';
default:
return 'default';
}
};

View File

@@ -0,0 +1,33 @@
import {
GEOSERVER_URL,
WORKSPACE,
} from '@/pages/Slave/SGW/Map/config/MapConfig';
import ImageLayer from 'ol/layer/Image';
import { ImageWMS } from 'ol/source';
/**
* Tạo một WMS tile layer.
*/
export function createWmsLayer(layerName: string) {
// return new TileLayer({
// visible: true,
// source: new TileWMS({
// url: `${GEOSERVER_URL}/${WORKSPACE}/wms`,
// params: {
// LAYERS: `${WORKSPACE}:${layerName}`,
// TILED: true,
// FORMAT: "image/png",
// TRANSPARENT: true,
// },
// serverType: "geoserver",
// crossOrigin: "anonymous",
// }),
// ...options,
// });
return new ImageLayer({
source: new ImageWMS({
url: `${GEOSERVER_URL}/${WORKSPACE}/wms`,
params: { LAYERS: `${WORKSPACE}:${layerName}` },
}),
});
}

View File

@@ -0,0 +1,25 @@
export const formatDate = (dateString: string | number | Date) => {
return new Date(dateString).toLocaleDateString('vi-VN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
};
/**
* Chuyển đổi unix time (string hoặc int) sang định dạng DD/MM/YY HH:mm:ss
* @param unixTime Unix time (giây hoặc mili giây, dạng string hoặc int)
* @returns Chuỗi thời gian định dạng DD/MM/YY HH:mm:ss
*/
export function formatUnixTime(unixTime: string | number): string {
let ts = typeof unixTime === 'string' ? parseInt(unixTime, 10) : unixTime;
if (ts < 1e12) ts *= 1000; // Nếu là giây, chuyển sang mili giây
const d = new Date(ts);
const pad = (n: number) => n.toString().padStart(2, '0');
return `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d
.getFullYear()
.toString()
.slice(-2)} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(
d.getSeconds(),
)}`;
}

View File

@@ -0,0 +1,18 @@
export const getTripState = (type: number | undefined) => {
switch (type) {
case 0:
return 'Chưa phê duyệt, đang tạo';
case 1:
return 'Đang chờ phê duyệt';
case 2:
return 'Đã phê duyệt';
case 3:
return 'Đã xuất bến';
case 4:
return 'Đã hoàn thành';
case 5:
return 'Đã huỷ';
default:
return '-';
}
};

View File

@@ -0,0 +1,70 @@
import ReconnectingWebSocket from 'reconnecting-websocket';
import { getToken } from '../../storage';
type MessageHandler = (data: any) => void;
class WSClient {
private ws: ReconnectingWebSocket | null = null;
private handler = new Set<MessageHandler>();
/**
* Kết nối tới WebSocket server.
* @param url Địa chỉ WebSocket server
* @param isAuthenticated Có sử dụng token xác thực hay không
*/
connect(url: string, isAuthenticated: boolean) {
if (this.ws) return;
let token = '';
if (isAuthenticated) {
token = getToken();
}
const wsUrl = isAuthenticated ? `${url}?token=${token}` : url;
this.ws = new ReconnectingWebSocket(wsUrl, [], {
maxRetries: 10,
maxReconnectionDelay: 10000,
});
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.handler.forEach((fn) => fn(data));
} catch (error) {
console.error('WS Parse Error: ', error);
}
};
}
/**
* Ngắt kết nối WebSocket và giải phóng tài nguyên.
*/
disconnect() {
this.ws?.close();
this.ws = null;
}
/**
* Gửi dữ liệu qua WebSocket.
* @param data Dữ liệu cần gửi (sẽ được stringify)
*/
send(data: any) {
this.ws?.send(JSON.stringify(data));
}
/**
* Đăng ký callback để nhận dữ liệu từ WebSocket.
* @param cb Hàm callback xử lý dữ liệu nhận được
* @returns Hàm hủy đăng ký callback
*/
subscribe(cb: MessageHandler) {
this.handler.add(cb);
return () => this.handler.delete(cb);
}
/**
* Kiểm tra trạng thái kết nối WebSocket.
* @returns true nếu đã kết nối, ngược lại là false
*/
isConnected() {
return this.ws?.readyState === WebSocket.OPEN;
}
}
export const wsClient = new WSClient();