Compare commits
6 Commits
1a06328c77
...
a11e2c2991
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a11e2c2991 | ||
| c9aeca0ed9 | |||
|
|
fea9cca865 | ||
| 1f35516e44 | |||
|
|
17d246d5ef | ||
|
|
b0b09a86b7 |
@@ -3,6 +3,7 @@ import {
|
|||||||
alarmsRoute,
|
alarmsRoute,
|
||||||
commonManagerRoutes,
|
commonManagerRoutes,
|
||||||
loginRoute,
|
loginRoute,
|
||||||
|
managerCameraRoute,
|
||||||
managerRouteBase,
|
managerRouteBase,
|
||||||
notFoundRoute,
|
notFoundRoute,
|
||||||
profileRoute,
|
profileRoute,
|
||||||
@@ -25,7 +26,7 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
...managerRouteBase,
|
...managerRouteBase,
|
||||||
routes: [...commonManagerRoutes],
|
routes: [...commonManagerRoutes, managerCameraRoute],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
# ✅ 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
|
|
||||||
@@ -80,6 +80,11 @@ export const commonManagerRoutes = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const managerCameraRoute = {
|
||||||
|
path: '/manager/devices/:thingId/camera',
|
||||||
|
component: './Manager/Device/Camera',
|
||||||
|
};
|
||||||
|
|
||||||
export const managerRouteBase = {
|
export const managerRouteBase = {
|
||||||
name: 'manager',
|
name: 'manager',
|
||||||
icon: 'icon-setting',
|
icon: 'icon-setting',
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ import {
|
|||||||
} from './utils/storage';
|
} from './utils/storage';
|
||||||
const isProdBuild = process.env.NODE_ENV === 'production';
|
const isProdBuild = process.env.NODE_ENV === 'production';
|
||||||
export type InitialStateResponse = {
|
export type InitialStateResponse = {
|
||||||
getUserProfile?: () => Promise<MasterModel.ProfileResponse | undefined>;
|
getUserProfile?: () => Promise<MasterModel.UserResponse | undefined>;
|
||||||
currentUserProfile?: MasterModel.ProfileResponse;
|
currentUserProfile?: MasterModel.UserResponse;
|
||||||
theme?: 'light' | 'dark';
|
theme?: 'light' | 'dark';
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,7 +86,7 @@ export const layout: RunTimeLayoutConfig = ({ initialState }) => {
|
|||||||
contentWidth: 'Fluid',
|
contentWidth: 'Fluid',
|
||||||
navTheme: isDark ? 'realDark' : 'light',
|
navTheme: isDark ? 'realDark' : 'light',
|
||||||
splitMenus: true,
|
splitMenus: true,
|
||||||
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_84vdbef39dp.js',
|
iconfontUrl: '//at.alicdn.com/t/c/font_5096559_pwy498d2aw.js',
|
||||||
contentStyle: {
|
contentStyle: {
|
||||||
padding: 0,
|
padding: 0,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Dropdown } from 'antd';
|
|||||||
export const AvatarDropdown = ({
|
export const AvatarDropdown = ({
|
||||||
currentUserProfile,
|
currentUserProfile,
|
||||||
}: {
|
}: {
|
||||||
currentUserProfile?: MasterModel.ProfileResponse;
|
currentUserProfile?: MasterModel.UserResponse;
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createFromIconfontCN } from '@ant-design/icons';
|
import { createFromIconfontCN } from '@ant-design/icons';
|
||||||
|
|
||||||
const IconFont = createFromIconfontCN({
|
const IconFont = createFromIconfontCN({
|
||||||
scriptUrl: '//at.alicdn.com/t/c/font_5096559_84vdbef39dp.js',
|
scriptUrl: '//at.alicdn.com/t/c/font_5096559_pwy498d2aw.js',
|
||||||
});
|
});
|
||||||
|
|
||||||
export default IconFont;
|
export default IconFont;
|
||||||
|
|||||||
298
src/components/shared/PhotoActionModal.tsx
Normal file
298
src/components/shared/PhotoActionModal.tsx
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
import { HTTPSTATUS } from '@/const';
|
||||||
|
import {
|
||||||
|
apiDeletePhoto,
|
||||||
|
apiGetPhoto,
|
||||||
|
apiGetTagsPhoto,
|
||||||
|
apiUploadPhoto,
|
||||||
|
} from '@/services/slave/sgw/PhotoController';
|
||||||
|
import { ModalForm, ProFormUploadButton } from '@ant-design/pro-components';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Divider, message } from 'antd';
|
||||||
|
import { UploadFile } from 'antd/lib';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
type PhotoActionModalProps = {
|
||||||
|
isOpen?: boolean;
|
||||||
|
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
type: SgwModel.PhotoGetParams['type'];
|
||||||
|
id: string | number;
|
||||||
|
hasSubPhotos?: boolean;
|
||||||
|
};
|
||||||
|
const PhotoActionModal = ({
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
type,
|
||||||
|
id,
|
||||||
|
hasSubPhotos = true,
|
||||||
|
}: PhotoActionModalProps) => {
|
||||||
|
const [imageMain, setImageMain] = useState<UploadFile[]>([]);
|
||||||
|
const [imageSubs, setImageSubs] = useState<UploadFile[]>([]);
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
const intl = useIntl();
|
||||||
|
const fetchImageByTag = async (tag: string): Promise<UploadFile | null> => {
|
||||||
|
try {
|
||||||
|
const resp = await apiGetPhoto(type, id, tag);
|
||||||
|
if (resp.status !== HTTPSTATUS.HTTP_SUCCESS) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const objectUrl = URL.createObjectURL(
|
||||||
|
new Blob([resp.data], { type: 'image/jpeg' }),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
uid: `-${tag}`,
|
||||||
|
name: `${tag}.jpg`,
|
||||||
|
status: 'done',
|
||||||
|
url: objectUrl,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadPhoto = async (
|
||||||
|
file: UploadFile,
|
||||||
|
tag: string,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const resp = await apiUploadPhoto(
|
||||||
|
type,
|
||||||
|
String(id),
|
||||||
|
file.originFileObj as File,
|
||||||
|
tag,
|
||||||
|
);
|
||||||
|
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw new Error('Upload photo failed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error when upload image: ', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleDeletePhoto = async (tag: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const resp = await apiDeletePhoto(type, String(id), tag);
|
||||||
|
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw new Error('Delete photo failed');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error when delete image: ', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalForm
|
||||||
|
open={isOpen}
|
||||||
|
submitter={false}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
layout="vertical"
|
||||||
|
request={async () => {
|
||||||
|
// 1. Lấy ảnh chính (tag 'main')
|
||||||
|
const mainImage = await fetchImageByTag('main');
|
||||||
|
setImageMain(mainImage ? [mainImage] : []);
|
||||||
|
|
||||||
|
if (hasSubPhotos) {
|
||||||
|
// 2. Lấy danh sách tags
|
||||||
|
try {
|
||||||
|
const tagsResp = await apiGetTagsPhoto(type, id);
|
||||||
|
if (tagsResp?.tags && Array.isArray(tagsResp.tags)) {
|
||||||
|
// Lọc bỏ tag 'main' và lấy các tag còn lại
|
||||||
|
const subTags = tagsResp.tags.filter(
|
||||||
|
(tag: string) => tag !== 'main',
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Lấy ảnh cho từng tag phụ
|
||||||
|
const subImages: UploadFile[] = [];
|
||||||
|
for (const tag of subTags) {
|
||||||
|
const img = await fetchImageByTag(tag);
|
||||||
|
if (img) {
|
||||||
|
subImages.push(img);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setImageSubs(subImages);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Không có tags hoặc lỗi
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{contextHolder}
|
||||||
|
<Divider>
|
||||||
|
{intl.formatMessage({ id: 'photo.main', defaultMessage: 'Ảnh chính' })}
|
||||||
|
</Divider>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormUploadButton
|
||||||
|
name="main-picture"
|
||||||
|
label={null}
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'photo.upload',
|
||||||
|
defaultMessage: 'Chọn ảnh',
|
||||||
|
})}
|
||||||
|
accept="image/*"
|
||||||
|
max={1}
|
||||||
|
transform={(value) => ({ upload: value })}
|
||||||
|
fieldProps={{
|
||||||
|
onChange: async (info) => {
|
||||||
|
if (info.file.status !== 'removed') {
|
||||||
|
const isSuccess = await handleUploadPhoto(info.file, 'main');
|
||||||
|
if (!isSuccess) {
|
||||||
|
messageApi.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'photo.update_fail',
|
||||||
|
defaultMessage: 'Cập nhật ảnh thất bại',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setImageMain([]);
|
||||||
|
} else {
|
||||||
|
messageApi.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'photo.update_success',
|
||||||
|
defaultMessage: 'Cập nhật ảnh thành công',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Dùng luôn file local để tạo URL, đỡ gọi API
|
||||||
|
const objectUrl = URL.createObjectURL(
|
||||||
|
info.file.originFileObj as File,
|
||||||
|
);
|
||||||
|
setImageMain([
|
||||||
|
{
|
||||||
|
uid: info.file.uid,
|
||||||
|
name: info.file.name,
|
||||||
|
status: 'done',
|
||||||
|
url: objectUrl,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
listType: 'picture-card',
|
||||||
|
fileList: imageMain,
|
||||||
|
maxCount: 1,
|
||||||
|
onRemove: async () => {
|
||||||
|
const isSuccess = await handleDeletePhoto('main');
|
||||||
|
if (!isSuccess) {
|
||||||
|
messageApi.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'photo.delete_fail',
|
||||||
|
defaultMessage: 'Xóa ảnh thất bại',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
messageApi.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'photo.delete_success',
|
||||||
|
defaultMessage: 'Xóa ảnh thành công',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setImageMain([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{hasSubPhotos && (
|
||||||
|
<>
|
||||||
|
<Divider>
|
||||||
|
{intl.formatMessage({ id: 'photo.sub', defaultMessage: 'Ảnh phụ' })}
|
||||||
|
</Divider>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: imageSubs.length > 0 ? 'flex-start' : 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormUploadButton
|
||||||
|
name="sub-picture"
|
||||||
|
label={null}
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'photo.upload',
|
||||||
|
defaultMessage: 'Chọn ảnh',
|
||||||
|
})}
|
||||||
|
accept="image/*"
|
||||||
|
max={10}
|
||||||
|
transform={(value) => ({ upload: value })}
|
||||||
|
fieldProps={{
|
||||||
|
onChange: async (info) => {
|
||||||
|
// Tìm file mới được thêm (so sánh với imageSubs hiện tại)
|
||||||
|
const existingUids = new Set(imageSubs.map((f) => f.uid));
|
||||||
|
const newFiles = info.fileList.filter(
|
||||||
|
(f) => !existingUids.has(f.uid) && f.originFileObj,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const file of newFiles) {
|
||||||
|
// Tạo tag random
|
||||||
|
const randomTag = `sub_${Date.now()}_${Math.random()
|
||||||
|
.toString(36)
|
||||||
|
.substring(2, 9)}`;
|
||||||
|
const isSuccess = await handleUploadPhoto(file, randomTag);
|
||||||
|
if (!isSuccess) {
|
||||||
|
messageApi.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'photo.update_fail',
|
||||||
|
defaultMessage: 'Cập nhật ảnh thất bại',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setImageSubs(imageSubs); // Revert về state cũ
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messageApi.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'photo.update_success',
|
||||||
|
defaultMessage: 'Thêm ảnh thành công',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Cập nhật file với tag
|
||||||
|
(file as any).tag = randomTag;
|
||||||
|
file.url = URL.createObjectURL(file.originFileObj as File);
|
||||||
|
file.status = 'done';
|
||||||
|
}
|
||||||
|
setImageSubs(info.fileList);
|
||||||
|
},
|
||||||
|
listType: 'picture-card',
|
||||||
|
fileList: imageSubs,
|
||||||
|
onRemove: async (file) => {
|
||||||
|
// Lấy tag từ file (được lưu khi fetch hoặc upload)
|
||||||
|
const tag = (file as any).tag || file.uid?.replace(/^-/, '');
|
||||||
|
const isSuccess = await handleDeletePhoto(tag);
|
||||||
|
if (!isSuccess) {
|
||||||
|
messageApi.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'photo.delete_fail',
|
||||||
|
defaultMessage: 'Xóa ảnh thất bại',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
messageApi.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'photo.delete_success',
|
||||||
|
defaultMessage: 'Xóa ảnh thành công',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setImageSubs((prev) =>
|
||||||
|
prev.filter((f) => f.uid !== file.uid),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PhotoActionModal;
|
||||||
@@ -6,10 +6,9 @@ import {
|
|||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useIntl } from '@umijs/max';
|
import { useIntl } from '@umijs/max';
|
||||||
import { Flex, Tag, Tooltip } from 'antd';
|
import { Flex, Tag, theme, Tooltip } from 'antd';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { TagStateCallbackPayload } from '../../pages/Slave/SGW/Map/type';
|
import { TagStateCallbackPayload } from '../../pages/Slave/SGW/Map/type';
|
||||||
import style from './index.less';
|
|
||||||
|
|
||||||
type TagStateProps = {
|
type TagStateProps = {
|
||||||
normalCount?: number;
|
normalCount?: number;
|
||||||
@@ -35,7 +34,59 @@ const TagState = ({
|
|||||||
sos: false,
|
sos: false,
|
||||||
disconnected: false,
|
disconnected: false,
|
||||||
});
|
});
|
||||||
|
const { token } = theme.useToken();
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
|
// Style variants using antd theme tokens for dark mode support
|
||||||
|
const getTagStyle = (
|
||||||
|
type: 'normal' | 'warning' | 'critical' | 'offline',
|
||||||
|
isActive: boolean,
|
||||||
|
) => {
|
||||||
|
const baseStyle = {
|
||||||
|
borderRadius: token.borderRadiusSM,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderStyle: 'solid' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === 'normal') {
|
||||||
|
return {
|
||||||
|
...baseStyle,
|
||||||
|
color: isActive ? token.colorSuccess : token.colorSuccess,
|
||||||
|
backgroundColor: isActive
|
||||||
|
? token.colorSuccessBg
|
||||||
|
: token.colorBgContainer,
|
||||||
|
borderColor: token.colorSuccessBorder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'warning') {
|
||||||
|
return {
|
||||||
|
...baseStyle,
|
||||||
|
color: isActive ? token.colorWarning : token.colorWarning,
|
||||||
|
backgroundColor: isActive
|
||||||
|
? token.colorWarningBg
|
||||||
|
: token.colorBgContainer,
|
||||||
|
borderColor: token.colorWarningBorder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (type === 'critical') {
|
||||||
|
return {
|
||||||
|
...baseStyle,
|
||||||
|
color: isActive ? token.colorError : token.colorError,
|
||||||
|
backgroundColor: isActive ? token.colorErrorBg : token.colorBgContainer,
|
||||||
|
borderColor: token.colorErrorBorder,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// offline
|
||||||
|
return {
|
||||||
|
...baseStyle,
|
||||||
|
color: token.colorTextSecondary,
|
||||||
|
backgroundColor: isActive
|
||||||
|
? token.colorFillSecondary
|
||||||
|
: token.colorBgContainer,
|
||||||
|
borderColor: token.colorBorder,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const handleTagClick = (key: keyof typeof activeStates) => {
|
const handleTagClick = (key: keyof typeof activeStates) => {
|
||||||
const newStates = { ...activeStates, [key]: !activeStates[key] };
|
const newStates = { ...activeStates, [key]: !activeStates[key] };
|
||||||
setActiveStates(newStates);
|
setActiveStates(newStates);
|
||||||
@@ -45,39 +96,11 @@ const TagState = ({
|
|||||||
isWarning: newStates.warning,
|
isWarning: newStates.warning,
|
||||||
isCritical: newStates.critical,
|
isCritical: newStates.critical,
|
||||||
isSos: newStates.sos,
|
isSos: newStates.sos,
|
||||||
isDisconected: newStates.disconnected,
|
isDisconnected: 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 (
|
return (
|
||||||
<Flex
|
<Flex
|
||||||
gap={1}
|
gap={1}
|
||||||
@@ -94,13 +117,12 @@ const TagState = ({
|
|||||||
{sosCount !== undefined && (
|
{sosCount !== undefined && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={intl.formatMessage({
|
title={intl.formatMessage({
|
||||||
id: 'thing.status.sos',
|
id: 'common.level.sos',
|
||||||
defaultMessage: 'SOS',
|
defaultMessage: 'SOS',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Tag.CheckableTag
|
<Tag.CheckableTag
|
||||||
className={activeStates.sos ? style.criticalActive : style.critical}
|
style={getTagStyle('critical', activeStates.sos)}
|
||||||
// style={tagStyles.sos}
|
|
||||||
icon={<AlertOutlined />}
|
icon={<AlertOutlined />}
|
||||||
checked={activeStates.sos}
|
checked={activeStates.sos}
|
||||||
onChange={() => handleTagClick('sos')}
|
onChange={() => handleTagClick('sos')}
|
||||||
@@ -113,13 +135,12 @@ const TagState = ({
|
|||||||
{/* {normalCount > 0 && ( */}
|
{/* {normalCount > 0 && ( */}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={intl.formatMessage({
|
title={intl.formatMessage({
|
||||||
id: 'thing.status.normal',
|
id: 'common.level.normal',
|
||||||
defaultMessage: 'Normal',
|
defaultMessage: 'Normal',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Tag.CheckableTag
|
<Tag.CheckableTag
|
||||||
className={activeStates.normal ? style.normalActive : style.normal}
|
style={getTagStyle('normal', activeStates.normal)}
|
||||||
// style={tagStyles.normal}
|
|
||||||
icon={<CheckOutlined />}
|
icon={<CheckOutlined />}
|
||||||
checked={activeStates.normal}
|
checked={activeStates.normal}
|
||||||
onChange={() => handleTagClick('normal')}
|
onChange={() => handleTagClick('normal')}
|
||||||
@@ -131,14 +152,13 @@ const TagState = ({
|
|||||||
{/* {warningCount > 0 && ( */}
|
{/* {warningCount > 0 && ( */}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={intl.formatMessage({
|
title={intl.formatMessage({
|
||||||
id: 'thing.status.warning',
|
id: 'common.level.warning',
|
||||||
defaultMessage: 'Warning',
|
defaultMessage: 'Warning',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Tag.CheckableTag
|
<Tag.CheckableTag
|
||||||
className={activeStates.warning ? style.warningActive : style.warning}
|
style={getTagStyle('warning', activeStates.warning)}
|
||||||
icon={<WarningOutlined />}
|
icon={<WarningOutlined />}
|
||||||
// style={tagStyles.warning}
|
|
||||||
checked={activeStates.warning}
|
checked={activeStates.warning}
|
||||||
onChange={() => handleTagClick('warning')}
|
onChange={() => handleTagClick('warning')}
|
||||||
>
|
>
|
||||||
@@ -149,16 +169,13 @@ const TagState = ({
|
|||||||
{/* {criticalCount > 0 && ( */}
|
{/* {criticalCount > 0 && ( */}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={intl.formatMessage({
|
title={intl.formatMessage({
|
||||||
id: 'thing.status.critical',
|
id: 'common.level.critical',
|
||||||
defaultMessage: 'Critical',
|
defaultMessage: 'Critical',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Tag.CheckableTag
|
<Tag.CheckableTag
|
||||||
className={
|
style={getTagStyle('critical', activeStates.critical)}
|
||||||
activeStates.critical ? style.criticalActive : style.critical
|
|
||||||
}
|
|
||||||
icon={<ExclamationOutlined />}
|
icon={<ExclamationOutlined />}
|
||||||
// style={tagStyles.critical}
|
|
||||||
checked={activeStates.critical}
|
checked={activeStates.critical}
|
||||||
onChange={() => handleTagClick('critical')}
|
onChange={() => handleTagClick('critical')}
|
||||||
>
|
>
|
||||||
@@ -169,16 +186,13 @@ const TagState = ({
|
|||||||
{/* {disconnectedCount > 0 && ( */}
|
{/* {disconnectedCount > 0 && ( */}
|
||||||
<Tooltip
|
<Tooltip
|
||||||
title={intl.formatMessage({
|
title={intl.formatMessage({
|
||||||
id: 'thing.status.disconnected',
|
id: 'common.level.disconnected',
|
||||||
defaultMessage: 'Disconnected',
|
defaultMessage: 'Disconnected',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Tag.CheckableTag
|
<Tag.CheckableTag
|
||||||
className={
|
style={getTagStyle('offline', activeStates.disconnected)}
|
||||||
activeStates.disconnected ? style.offlineActive : style.offline
|
|
||||||
}
|
|
||||||
icon={<DisconnectOutlined />}
|
icon={<DisconnectOutlined />}
|
||||||
// style={tagStyles.disconnected}
|
|
||||||
checked={activeStates.disconnected}
|
checked={activeStates.disconnected}
|
||||||
onChange={() => handleTagClick('disconnected')}
|
onChange={() => handleTagClick('disconnected')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -5,18 +5,28 @@ import {
|
|||||||
STATUS_WARNING,
|
STATUS_WARNING,
|
||||||
} from '@/constants';
|
} from '@/constants';
|
||||||
import { Badge } from 'antd';
|
import { Badge } from 'antd';
|
||||||
|
import IconFont from '../IconFont';
|
||||||
|
|
||||||
export const getBadgeStatus = (status: number) => {
|
export const getBadgeStatus = (status: number) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case STATUS_NORMAL:
|
case STATUS_NORMAL:
|
||||||
return <Badge status="success" />;
|
return <Badge size="default" status="success" />;
|
||||||
case STATUS_WARNING:
|
case STATUS_WARNING:
|
||||||
return <Badge status="warning" />;
|
return <Badge size="default" status="warning" />;
|
||||||
case STATUS_DANGEROUS:
|
case STATUS_DANGEROUS:
|
||||||
return <Badge status="error" />;
|
return <Badge size="default" status="error" />;
|
||||||
case STATUS_SOS:
|
case STATUS_SOS:
|
||||||
return <Badge status="error" />;
|
return <Badge size="default" status="error" />;
|
||||||
default:
|
default:
|
||||||
return <Badge status="default" />;
|
return <Badge size="default" status="default" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBadgeConnection = (online: boolean) => {
|
||||||
|
switch (online) {
|
||||||
|
case true:
|
||||||
|
return <Badge status="processing" />;
|
||||||
|
default:
|
||||||
|
return <IconFont type="icon-cloud-disconnect" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
2
src/const.ts
Normal file
2
src/const.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
// Re-export from constants for backward compatibility
|
||||||
|
export * from './constants';
|
||||||
@@ -16,7 +16,7 @@ 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_TRIPS_BY_ID = '/api/sgw/trips-by-id';
|
||||||
export const SGW_ROUTE_UPDATE_TRIP_STATUS = '/api/sgw/update-trip-status';
|
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_HAUL_HANDLE = '/api/sgw/haul-handle';
|
||||||
export const SGW_ROUTE_GET_FISH = '/api/sgw/fish-species';
|
export const SGW_ROUTE_GET_FISH = '/api/sgw/fishspecies-list';
|
||||||
export const SGW_ROUTE_UPDATE_FISHING_LOGS = '/api/sgw/update-fishing-logs';
|
export const SGW_ROUTE_UPDATE_FISHING_LOGS = '/api/sgw/update-fishing-logs';
|
||||||
|
|
||||||
// Crew API Routes
|
// Crew API Routes
|
||||||
@@ -26,7 +26,12 @@ export const SGW_ROUTE_TRIPS_CREWS = '/api/sgw/trips/crews';
|
|||||||
|
|
||||||
// Photo API Routes
|
// Photo API Routes
|
||||||
export const SGW_ROUTE_PHOTO = '/api/sgw/photo';
|
export const SGW_ROUTE_PHOTO = '/api/sgw/photo';
|
||||||
|
export const SGW_ROUTE_PHOTO_TAGS = '/api/sgw/list-photo';
|
||||||
|
|
||||||
// Banzone API Routes
|
// Banzone API Routes
|
||||||
export const SGW_ROUTE_BANZONES = '/api/sgw/banzones';
|
export const SGW_ROUTE_BANZONES = '/api/sgw/banzones';
|
||||||
export const SGW_ROUTE_BANZONES_LIST = '/api/sgw/banzones/list';
|
export const SGW_ROUTE_BANZONES_LIST = '/api/sgw/banzones/list';
|
||||||
|
|
||||||
|
// Fish API Routes
|
||||||
|
export const SGW_ROUTE_CREATE_OR_UPDATE_FISH = '/api/sgw/fishspecies';
|
||||||
|
export const SGW_ROUTE_CREATE_OR_UPDATE_FISHING_LOGS = '/api/sgw/fishingLog';
|
||||||
|
|||||||
@@ -33,11 +33,15 @@ export default {
|
|||||||
'common.theme.dark': 'Dark Theme',
|
'common.theme.dark': 'Dark Theme',
|
||||||
'common.paginations.things': 'things',
|
'common.paginations.things': 'things',
|
||||||
'common.paginations.of': 'of',
|
'common.paginations.of': 'of',
|
||||||
|
'common.of': 'of',
|
||||||
'common.name': 'Name',
|
'common.name': 'Name',
|
||||||
'common.name.required': 'Name is required',
|
'common.name.required': 'Name is required',
|
||||||
|
'common.note': 'Note',
|
||||||
|
'common.image': 'Image',
|
||||||
'common.type': 'Type',
|
'common.type': 'Type',
|
||||||
'common.type.placeholder': 'Select Type',
|
'common.type.placeholder': 'Select Type',
|
||||||
'common.status': 'Status',
|
'common.status': 'Status',
|
||||||
|
'common.connect': 'Connection',
|
||||||
'common.province': 'Province',
|
'common.province': 'Province',
|
||||||
'common.description': 'Description',
|
'common.description': 'Description',
|
||||||
'common.description.required': 'Description is required',
|
'common.description.required': 'Description is required',
|
||||||
@@ -48,6 +52,7 @@ export default {
|
|||||||
'common.updated_at': 'Updated At',
|
'common.updated_at': 'Updated At',
|
||||||
'common.undefined': 'Undefined',
|
'common.undefined': 'Undefined',
|
||||||
'common.not_empty': 'Cannot be empty!',
|
'common.not_empty': 'Cannot be empty!',
|
||||||
|
'common.level.disconnected': 'Disconnected',
|
||||||
'common.level.normal': 'Normal',
|
'common.level.normal': 'Normal',
|
||||||
'common.level.warning': 'Warning',
|
'common.level.warning': 'Warning',
|
||||||
'common.level.critical': 'Critical',
|
'common.level.critical': 'Critical',
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ export default {
|
|||||||
'master.devices.create.error': 'Device creation failed',
|
'master.devices.create.error': 'Device creation failed',
|
||||||
'master.devices.groups': 'Groups',
|
'master.devices.groups': 'Groups',
|
||||||
'master.devices.groups.required': 'Please select groups',
|
'master.devices.groups.required': 'Please select groups',
|
||||||
|
// Update info device
|
||||||
|
'master.devices.update.success': 'Updated successfully',
|
||||||
|
'master.devices.update.error': 'Update failed',
|
||||||
// Edit device modal
|
// Edit device modal
|
||||||
'master.devices.update.title': 'Update device',
|
'master.devices.update.title': 'Update device',
|
||||||
'master.devices.ok': 'OK',
|
'master.devices.ok': 'OK',
|
||||||
@@ -32,4 +35,13 @@ export default {
|
|||||||
'master.devices.address': 'Address',
|
'master.devices.address': 'Address',
|
||||||
'master.devices.address.placeholder': 'Enter address',
|
'master.devices.address.placeholder': 'Enter address',
|
||||||
'master.devices.address.required': 'Please enter address',
|
'master.devices.address.required': 'Please enter address',
|
||||||
|
// Location modal
|
||||||
|
'master.devices.location.title': 'Update location',
|
||||||
|
'master.devices.location.latitude': 'Latitude',
|
||||||
|
'master.devices.location.latitude.required': 'Please enter latitude',
|
||||||
|
'master.devices.location.longitude': 'Longitude',
|
||||||
|
'master.devices.location.longitude.required': 'Please enter longitude',
|
||||||
|
'master.devices.location.placeholder': 'Enter data',
|
||||||
|
'master.devices.location.update.success': 'Location updated successfully',
|
||||||
|
'master.devices.location.update.error': 'Location update failed',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,19 @@
|
|||||||
|
import sgwFish from './sgw-fish-en';
|
||||||
|
import sgwMap from './sgw-map-en';
|
||||||
import sgwMenu from './sgw-menu-en';
|
import sgwMenu from './sgw-menu-en';
|
||||||
|
import sgwPhoto from './sgw-photo-en';
|
||||||
|
import sgwShip from './sgw-ship-en';
|
||||||
|
import sgwTrip from './sgw-trip-en';
|
||||||
|
import sgwZone from './sgw-zone-en';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
'sgw.title': 'Sea Gateway',
|
'sgw.title': 'Sea Gateway',
|
||||||
'sgw.ship': 'Ship',
|
'sgw.ship': 'Ship',
|
||||||
...sgwMenu,
|
...sgwMenu,
|
||||||
|
...sgwTrip,
|
||||||
|
...sgwMap,
|
||||||
|
...sgwShip,
|
||||||
|
...sgwFish,
|
||||||
|
...sgwPhoto,
|
||||||
|
...sgwZone,
|
||||||
};
|
};
|
||||||
|
|||||||
53
src/locales/en-US/slave/sgw/sgw-fish-en.ts
Normal file
53
src/locales/en-US/slave/sgw/sgw-fish-en.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export default {
|
||||||
|
// Fish group
|
||||||
|
'fish.fish_group': 'Fish Group',
|
||||||
|
'fish.fish_group.tooltip': 'Enter fish group name',
|
||||||
|
'fish.fish_group.placeholder': 'Enter fish group',
|
||||||
|
|
||||||
|
// Rarity
|
||||||
|
'fish.rarity': 'Rarity Level',
|
||||||
|
'fish.rarity.placeholder': 'Select rarity level',
|
||||||
|
'fish.rarity.normal': 'Normal',
|
||||||
|
'fish.rarity.sensitive': 'Sensitive',
|
||||||
|
'fish.rarity.near_threatened': 'Near Threatened',
|
||||||
|
'fish.rarity.vulnerable': 'Vulnerable',
|
||||||
|
'fish.rarity.endangered': 'Endangered',
|
||||||
|
'fish.rarity.critically_endangered': 'Critically Endangered',
|
||||||
|
'fish.rarity.extinct_in_the_wild': 'Extinct in the Wild',
|
||||||
|
'fish.rarity.data_deficient': 'Data Deficient',
|
||||||
|
|
||||||
|
// Fish name
|
||||||
|
'fish.name': 'Fish Name',
|
||||||
|
'fish.name.tooltip': 'Enter fish name',
|
||||||
|
'fish.name.placeholder': 'Enter fish name',
|
||||||
|
'fish.name.required': 'Please enter fish name',
|
||||||
|
|
||||||
|
// Specific name
|
||||||
|
'fish.specific_name': 'Scientific Name',
|
||||||
|
'fish.specific_name.placeholder': 'Enter scientific name',
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
'fish.create.title': 'Add New Fish Species',
|
||||||
|
'fish.edit.title': 'Edit Fish Species',
|
||||||
|
'fish.delete.title': 'Delete Fish Species',
|
||||||
|
'fish.delete.confirm': 'Are you sure you want to delete this fish species?',
|
||||||
|
'fish.delete_confirm': 'Are you sure you want to delete this fish species?',
|
||||||
|
'fish.delete.success': 'Fish species deleted successfully',
|
||||||
|
'fish.delete.fail': 'Failed to delete fish species',
|
||||||
|
'fish.create.success': 'Fish species created successfully',
|
||||||
|
'fish.create.fail': 'Failed to create fish species',
|
||||||
|
'fish.update.success': 'Fish species updated successfully',
|
||||||
|
'fish.update.fail': 'Failed to update fish species',
|
||||||
|
|
||||||
|
// Table columns
|
||||||
|
'fish.table.name': 'Fish Name',
|
||||||
|
'fish.table.specific_name': 'Scientific Name',
|
||||||
|
'fish.table.fish_group': 'Fish Group',
|
||||||
|
'fish.table.rarity': 'Rarity Level',
|
||||||
|
'fish.table.actions': 'Actions',
|
||||||
|
|
||||||
|
// Search & Filter
|
||||||
|
'fish.search.placeholder': 'Search fish species...',
|
||||||
|
'fish.filter.group': 'Filter by group',
|
||||||
|
'fish.filter.rarity': 'Filter by rarity',
|
||||||
|
};
|
||||||
37
src/locales/en-US/slave/sgw/sgw-map-en.ts
Normal file
37
src/locales/en-US/slave/sgw/sgw-map-en.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export default {
|
||||||
|
// Map
|
||||||
|
'home.mapError': 'Map Error',
|
||||||
|
'map.ship_detail.heading': 'Heading',
|
||||||
|
'map.ship_detail.speed': 'Speed',
|
||||||
|
'map.ship_detail.name': 'Ship Information',
|
||||||
|
|
||||||
|
// Thing status
|
||||||
|
'thing.name': 'Device Name',
|
||||||
|
'thing.status': 'Status',
|
||||||
|
'thing.status.normal': 'Normal',
|
||||||
|
'thing.status.warning': 'Warning',
|
||||||
|
'thing.status.critical': 'Critical',
|
||||||
|
'thing.status.sos': 'SOS',
|
||||||
|
|
||||||
|
// Map layers
|
||||||
|
'map.layer.list': 'Map Layers',
|
||||||
|
'map.layer.fishing_ban_zone': 'Fishing Ban Zone',
|
||||||
|
'map.layer.entry_ban_zone': 'Entry Ban Zone',
|
||||||
|
'map.layer.boundary_lines': 'Boundary Lines',
|
||||||
|
'map.layer.ports': 'Ports',
|
||||||
|
|
||||||
|
// Map filters
|
||||||
|
'map.filter.name': 'Filter',
|
||||||
|
'map.filter.ship_name': 'Ship Name',
|
||||||
|
'map.filter.ship_name_tooltip': 'Enter ship name to search',
|
||||||
|
'map.filter.ship_reg_number': 'Registration Number',
|
||||||
|
'map.filter.ship_reg_number_tooltip':
|
||||||
|
'Enter ship registration number to search',
|
||||||
|
'map.filter.ship_length': 'Ship Length (m)',
|
||||||
|
'map.filter.ship_power': 'Power (HP)',
|
||||||
|
'map.filter.ship_type': 'Ship Type',
|
||||||
|
'map.filter.ship_type_placeholder': 'Select ship type',
|
||||||
|
'map.filter.ship_warning': 'Warning',
|
||||||
|
'map.filter.ship_warning_placeholder': 'Select warning type',
|
||||||
|
'map.filter.area_type': 'Area Type',
|
||||||
|
};
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
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.sgw.ships': 'Ships',
|
||||||
'menu.manager.sgw.fishes': 'Fishes',
|
'menu.sgw.fishes': 'Fishes',
|
||||||
'menu.manager.sgw.zones': 'Zones',
|
'menu.sgw.zones': 'Prohibited Zones',
|
||||||
|
|
||||||
|
// Manager menu
|
||||||
|
'menu.manager.sgw.fishes': 'Fish Management',
|
||||||
|
'menu.manager.sgw.zones': 'Zone Management',
|
||||||
};
|
};
|
||||||
|
|||||||
16
src/locales/en-US/slave/sgw/sgw-photo-en.ts
Normal file
16
src/locales/en-US/slave/sgw/sgw-photo-en.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export default {
|
||||||
|
'photo.main': 'Main Photo',
|
||||||
|
'photo.sub': 'Sub Photos',
|
||||||
|
'photo.upload': 'Upload Photo',
|
||||||
|
'photo.delete': 'Delete Photo',
|
||||||
|
'photo.delete.confirm': 'Are you sure you want to delete this photo?',
|
||||||
|
'photo.delete.success': 'Photo deleted successfully',
|
||||||
|
'photo.delete.fail': 'Failed to delete photo',
|
||||||
|
'photo.upload.success': 'Photo uploaded successfully',
|
||||||
|
'photo.upload.fail': 'Failed to upload photo',
|
||||||
|
'photo.upload.limit': 'Photo size must not exceed 5MB',
|
||||||
|
'photo.upload.format': 'Only JPG/PNG format supported',
|
||||||
|
'photo.manage': 'Manage Photos',
|
||||||
|
'photo.change': 'Change Photo',
|
||||||
|
'photo.add': 'Add Photo',
|
||||||
|
};
|
||||||
16
src/locales/en-US/slave/sgw/sgw-ship-en.ts
Normal file
16
src/locales/en-US/slave/sgw/sgw-ship-en.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export default {
|
||||||
|
// Pages - Ship List
|
||||||
|
'pages.ships.reg_number': 'Registration Number',
|
||||||
|
'pages.ships.name': 'Ship Name',
|
||||||
|
'pages.ships.type': 'Ship Type',
|
||||||
|
'pages.ships.home_port': 'Home Port',
|
||||||
|
'pages.ships.option': 'Options',
|
||||||
|
|
||||||
|
// Pages - Ship Create/Edit
|
||||||
|
'pages.ship.create.text': 'Create New Ship',
|
||||||
|
'pages.ships.create.title': 'Add New Ship',
|
||||||
|
'pages.ships.edit.title': 'Edit Ship',
|
||||||
|
|
||||||
|
// Pages - Things (Ship related)
|
||||||
|
'pages.things.fishing_license_expiry_date': 'Fishing License Expiry Date',
|
||||||
|
};
|
||||||
64
src/locales/en-US/slave/sgw/sgw-trip-en.ts
Normal file
64
src/locales/en-US/slave/sgw/sgw-trip-en.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
export default {
|
||||||
|
// Pages - Trip List
|
||||||
|
'pages.trips.name': 'Trip Name',
|
||||||
|
'pages.trips.ship_id': 'Ship',
|
||||||
|
'pages.trips.departure_time': 'Departure Time',
|
||||||
|
'pages.trips.arrival_time': 'Arrival Time',
|
||||||
|
'pages.trips.status': 'Status',
|
||||||
|
'pages.trips.status.created': 'Created',
|
||||||
|
'pages.trips.status.pending_approval': 'Pending Approval',
|
||||||
|
'pages.trips.status.approved': 'Approved',
|
||||||
|
'pages.trips.status.active': 'Active',
|
||||||
|
'pages.trips.status.completed': 'Completed',
|
||||||
|
'pages.trips.status.cancelled': 'Cancelled',
|
||||||
|
|
||||||
|
// Pages - Date filters
|
||||||
|
'pages.date.yesterday': 'Yesterday',
|
||||||
|
'pages.date.lastweek': 'Last Week',
|
||||||
|
'pages.date.lastmonth': 'Last Month',
|
||||||
|
|
||||||
|
// Pages - Things/Ship
|
||||||
|
'pages.things.createTrip.text': 'Create Trip',
|
||||||
|
'pages.things.option': 'Options',
|
||||||
|
|
||||||
|
// Trip badges
|
||||||
|
'trip.badge.active': 'Active',
|
||||||
|
'trip.badge.approved': 'Approved',
|
||||||
|
'trip.badge.cancelled': 'Cancelled',
|
||||||
|
'trip.badge.completed': 'Completed',
|
||||||
|
'trip.badge.notApproved': 'Not Approved',
|
||||||
|
'trip.badge.unknown': 'Unknown',
|
||||||
|
'trip.badge.waitingApproval': 'Waiting Approval',
|
||||||
|
|
||||||
|
// Cancel trip
|
||||||
|
'trip.cancelTrip.button': 'Cancel Trip',
|
||||||
|
'trip.cancelTrip.placeholder': 'Enter reason for cancellation',
|
||||||
|
'trip.cancelTrip.reason': 'Reason',
|
||||||
|
'trip.cancelTrip.title': 'Cancel Trip',
|
||||||
|
'trip.cancelTrip.validation': 'Please enter a reason',
|
||||||
|
|
||||||
|
// Trip cost
|
||||||
|
'trip.cost.amount': 'Amount',
|
||||||
|
'trip.cost.crewSalary': 'Crew Salary',
|
||||||
|
'trip.cost.food': 'Food',
|
||||||
|
'trip.cost.fuel': 'Fuel',
|
||||||
|
'trip.cost.grandTotal': 'Grand Total',
|
||||||
|
'trip.cost.iceSalt': 'Ice & Salt',
|
||||||
|
'trip.cost.price': 'Price',
|
||||||
|
'trip.cost.total': 'Total',
|
||||||
|
'trip.cost.type': 'Type',
|
||||||
|
'trip.cost.unit': 'Unit',
|
||||||
|
|
||||||
|
// Trip gear
|
||||||
|
'trip.gear.name': 'Gear Name',
|
||||||
|
'trip.gear.quantity': 'Quantity',
|
||||||
|
|
||||||
|
// Haul fish list
|
||||||
|
'trip.haulFishList.fishCondition': 'Condition',
|
||||||
|
'trip.haulFishList.fishName': 'Fish Name',
|
||||||
|
'trip.haulFishList.fishRarity': 'Rarity',
|
||||||
|
'trip.haulFishList.fishSize': 'Size',
|
||||||
|
'trip.haulFishList.gearUsage': 'Gear Usage',
|
||||||
|
'trip.haulFishList.title': 'Haul Fish List',
|
||||||
|
'trip.haulFishList.weight': 'Weight',
|
||||||
|
};
|
||||||
32
src/locales/en-US/slave/sgw/sgw-zone-en.ts
Normal file
32
src/locales/en-US/slave/sgw/sgw-zone-en.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export default {
|
||||||
|
// Table columns
|
||||||
|
'banzones.name': 'Zone Name',
|
||||||
|
'banzones.area': 'Province/City',
|
||||||
|
'banzones.description': 'Description',
|
||||||
|
'banzones.type': 'Type',
|
||||||
|
'banzones.conditions': 'Conditions',
|
||||||
|
'banzones.state': 'Status',
|
||||||
|
'banzones.action': 'Actions',
|
||||||
|
'banzones.title': 'zones',
|
||||||
|
'banzones.create': 'Create Zone',
|
||||||
|
|
||||||
|
// Zone types
|
||||||
|
'banzone.area.fishing_ban': 'Fishing Ban',
|
||||||
|
'banzone.area.move_ban': 'Movement Ban',
|
||||||
|
'banzone.area.safe': 'Safe Area',
|
||||||
|
|
||||||
|
// Status
|
||||||
|
'banzone.is_enable': 'Enabled',
|
||||||
|
'banzone.is_unenabled': 'Disabled',
|
||||||
|
|
||||||
|
// Shape types
|
||||||
|
'banzone.polygon': 'Polygon',
|
||||||
|
'banzone.polyline': 'Polyline',
|
||||||
|
'banzone.circle': 'Circle',
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
'banzone.notify.delete_zone_success': 'Zone deleted successfully',
|
||||||
|
'banzone.notify.delete_zone_confirm':
|
||||||
|
'Are you sure you want to delete this zone',
|
||||||
|
'banzone.notify.fail': 'Operation failed!',
|
||||||
|
};
|
||||||
@@ -32,6 +32,7 @@ export default {
|
|||||||
'common.theme.dark': 'Tối',
|
'common.theme.dark': 'Tối',
|
||||||
'common.paginations.things': 'thiết bị',
|
'common.paginations.things': 'thiết bị',
|
||||||
'common.paginations.of': 'trên',
|
'common.paginations.of': 'trên',
|
||||||
|
'common.of': 'trên',
|
||||||
'common.name': 'Tên',
|
'common.name': 'Tên',
|
||||||
'common.name.required': 'Tên không được để trống',
|
'common.name.required': 'Tên không được để trống',
|
||||||
'common.note': 'Ghi chú',
|
'common.note': 'Ghi chú',
|
||||||
@@ -39,6 +40,7 @@ export default {
|
|||||||
'common.type': 'Loại',
|
'common.type': 'Loại',
|
||||||
'common.type.placeholder': 'Chọn loại',
|
'common.type.placeholder': 'Chọn loại',
|
||||||
'common.status': 'Trạng thái',
|
'common.status': 'Trạng thái',
|
||||||
|
'common.connect': 'Kết nối',
|
||||||
'common.province': 'Tỉnh',
|
'common.province': 'Tỉnh',
|
||||||
'common.description': 'Mô tả',
|
'common.description': 'Mô tả',
|
||||||
'common.description.required': 'Mô tả không được để trống',
|
'common.description.required': 'Mô tả không được để trống',
|
||||||
@@ -49,6 +51,7 @@ export default {
|
|||||||
'common.updated_at': 'Ngày cập nhật',
|
'common.updated_at': 'Ngày cập nhật',
|
||||||
'common.undefined': 'Chưa xác định',
|
'common.undefined': 'Chưa xác định',
|
||||||
'common.not_empty': 'Không được để trống!',
|
'common.not_empty': 'Không được để trống!',
|
||||||
|
'common.level.disconnected': 'Mất kết nối',
|
||||||
'common.level.normal': 'Bình thường',
|
'common.level.normal': 'Bình thường',
|
||||||
'common.level.warning': 'Cảnh báo',
|
'common.level.warning': 'Cảnh báo',
|
||||||
'common.level.critical': 'Nguy hiểm',
|
'common.level.critical': 'Nguy hiểm',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export default {
|
|||||||
'master.thing.external_id': 'External ID',
|
'master.thing.external_id': 'External ID',
|
||||||
'master.thing.group': 'Nhóm',
|
'master.thing.group': 'Nhóm',
|
||||||
'master.thing.address': 'Địa chỉ',
|
'master.thing.address': 'Địa chỉ',
|
||||||
|
|
||||||
// Device translations
|
// Device translations
|
||||||
'master.devices.title': 'Quản lý thiết bị',
|
'master.devices.title': 'Quản lý thiết bị',
|
||||||
'master.devices.name': 'Tên',
|
'master.devices.name': 'Tên',
|
||||||
@@ -20,6 +21,9 @@ export default {
|
|||||||
'master.devices.create.error': 'Tạo thiết bị lỗi',
|
'master.devices.create.error': 'Tạo thiết bị lỗi',
|
||||||
'master.devices.groups': 'Đơn vị',
|
'master.devices.groups': 'Đơn vị',
|
||||||
'master.devices.groups.required': 'Vui lòng chọn đơn vị',
|
'master.devices.groups.required': 'Vui lòng chọn đơn vị',
|
||||||
|
// Update info device
|
||||||
|
'master.devices.update.success': 'Cập nhật thành công',
|
||||||
|
'master.devices.update.error': 'Cập nhật thất bại',
|
||||||
// Edit device modal
|
// Edit device modal
|
||||||
'master.devices.update.title': 'Cập nhật thiết bị',
|
'master.devices.update.title': 'Cập nhật thiết bị',
|
||||||
'master.devices.ok': 'Đồng ý',
|
'master.devices.ok': 'Đồng ý',
|
||||||
@@ -31,4 +35,13 @@ export default {
|
|||||||
'master.devices.address': 'Địa chỉ',
|
'master.devices.address': 'Địa chỉ',
|
||||||
'master.devices.address.placeholder': 'Nhập địa chỉ',
|
'master.devices.address.placeholder': 'Nhập địa chỉ',
|
||||||
'master.devices.address.required': 'Vui lòng nhập địa chỉ',
|
'master.devices.address.required': 'Vui lòng nhập địa chỉ',
|
||||||
|
// Location modal
|
||||||
|
'master.devices.location.title': 'Cập nhật vị trí',
|
||||||
|
'master.devices.location.latitude': 'Vị độ',
|
||||||
|
'master.devices.location.latitude.required': 'Vui lòng nhập vị độ',
|
||||||
|
'master.devices.location.longitude': 'Kinh độ',
|
||||||
|
'master.devices.location.longitude.required': 'Vui lòng nhập kinh độ',
|
||||||
|
'master.devices.location.placeholder': 'Nhập dữ liệu',
|
||||||
|
'master.devices.location.update.success': 'Cập nhật vị trí thành công',
|
||||||
|
'master.devices.location.update.error': 'Cập nhật vị trí thất bại',
|
||||||
};
|
};
|
||||||
|
|||||||
53
src/locales/vi-VN/slave/sgw/sgw-fish-vi.ts
Normal file
53
src/locales/vi-VN/slave/sgw/sgw-fish-vi.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export default {
|
||||||
|
// Fish group
|
||||||
|
'fish.fish_group': 'Nhóm loài cá',
|
||||||
|
'fish.fish_group.tooltip': 'Nhập tên nhóm loài cá',
|
||||||
|
'fish.fish_group.placeholder': 'Nhập nhóm loài cá',
|
||||||
|
|
||||||
|
// Rarity
|
||||||
|
'fish.rarity': 'Mức độ quý hiếm',
|
||||||
|
'fish.rarity.placeholder': 'Chọn mức độ quý hiếm',
|
||||||
|
'fish.rarity.normal': 'Bình thường',
|
||||||
|
'fish.rarity.sensitive': 'Nhạy cảm',
|
||||||
|
'fish.rarity.near_threatened': 'Gần nguy cấp',
|
||||||
|
'fish.rarity.vulnerable': 'Sắp nguy cấp',
|
||||||
|
'fish.rarity.endangered': 'Nguy cấp',
|
||||||
|
'fish.rarity.critically_endangered': 'Cực kỳ nguy cấp',
|
||||||
|
'fish.rarity.extinct_in_the_wild': 'Tuyệt chủng trong tự nhiên',
|
||||||
|
'fish.rarity.data_deficient': 'Thiếu dữ liệu',
|
||||||
|
|
||||||
|
// Fish name
|
||||||
|
'fish.name': 'Tên loài cá',
|
||||||
|
'fish.name.tooltip': 'Nhập tên loài cá',
|
||||||
|
'fish.name.placeholder': 'Nhập tên loài cá',
|
||||||
|
'fish.name.required': 'Vui lòng nhập tên loài cá',
|
||||||
|
|
||||||
|
// Specific name
|
||||||
|
'fish.specific_name': 'Tên khoa học',
|
||||||
|
'fish.specific_name.placeholder': 'Nhập tên khoa học',
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
'fish.create.title': 'Thêm loài cá mới',
|
||||||
|
'fish.edit.title': 'Chỉnh sửa loài cá',
|
||||||
|
'fish.delete.title': 'Xóa loài cá',
|
||||||
|
'fish.delete.confirm': 'Bạn có chắc chắn muốn xóa loài cá này không?',
|
||||||
|
'fish.delete_confirm': 'Bạn có chắc chắn muốn xóa loài cá này không?',
|
||||||
|
'fish.delete.success': 'Xóa loài cá thành công',
|
||||||
|
'fish.delete.fail': 'Xóa loài cá thất bại',
|
||||||
|
'fish.create.success': 'Thêm loài cá thành công',
|
||||||
|
'fish.create.fail': 'Thêm loài cá thất bại',
|
||||||
|
'fish.update.success': 'Cập nhật loài cá thành công',
|
||||||
|
'fish.update.fail': 'Cập nhật loài cá thất bại',
|
||||||
|
|
||||||
|
// Table columns
|
||||||
|
'fish.table.name': 'Tên loài cá',
|
||||||
|
'fish.table.specific_name': 'Tên khoa học',
|
||||||
|
'fish.table.fish_group': 'Nhóm loài',
|
||||||
|
'fish.table.rarity': 'Mức độ quý hiếm',
|
||||||
|
'fish.table.actions': 'Hành động',
|
||||||
|
|
||||||
|
// Search & Filter
|
||||||
|
'fish.search.placeholder': 'Tìm kiếm loài cá...',
|
||||||
|
'fish.filter.group': 'Lọc theo nhóm',
|
||||||
|
'fish.filter.rarity': 'Lọc theo mức độ quý hiếm',
|
||||||
|
};
|
||||||
36
src/locales/vi-VN/slave/sgw/sgw-map-vi.ts
Normal file
36
src/locales/vi-VN/slave/sgw/sgw-map-vi.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
export default {
|
||||||
|
// Map
|
||||||
|
'home.mapError': 'Lỗi bản đồ',
|
||||||
|
'map.ship_detail.heading': 'Hướng di chuyển',
|
||||||
|
'map.ship_detail.speed': 'Tốc độ',
|
||||||
|
'map.ship_detail.name': 'Thông tin tàu',
|
||||||
|
|
||||||
|
// Thing status
|
||||||
|
'thing.name': 'Tên thiết bị',
|
||||||
|
'thing.status': 'Trạng thái',
|
||||||
|
'thing.status.normal': 'Bình thường',
|
||||||
|
'thing.status.warning': 'Cảnh báo',
|
||||||
|
'thing.status.critical': 'Nghiêm trọng',
|
||||||
|
'thing.status.sos': 'Khẩn cấp',
|
||||||
|
|
||||||
|
// Map layers
|
||||||
|
'map.layer.list': 'Danh sách lớp bản đồ',
|
||||||
|
'map.layer.fishing_ban_zone': 'Khu vực cấm đánh bắt',
|
||||||
|
'map.layer.entry_ban_zone': 'Khu vực cấm vào',
|
||||||
|
'map.layer.boundary_lines': 'Đường biên giới',
|
||||||
|
'map.layer.ports': 'Cảng',
|
||||||
|
|
||||||
|
// Map filters
|
||||||
|
'map.filter.name': 'Bộ lọc',
|
||||||
|
'map.filter.ship_name': 'Tên tàu',
|
||||||
|
'map.filter.ship_name_tooltip': 'Nhập tên tàu để tìm kiếm',
|
||||||
|
'map.filter.ship_reg_number': 'Số đăng ký',
|
||||||
|
'map.filter.ship_reg_number_tooltip': 'Nhập số đăng ký tàu để tìm kiếm',
|
||||||
|
'map.filter.ship_length': 'Chiều dài tàu (m)',
|
||||||
|
'map.filter.ship_power': 'Công suất (HP)',
|
||||||
|
'map.filter.ship_type': 'Loại tàu',
|
||||||
|
'map.filter.ship_type_placeholder': 'Chọn loại tàu',
|
||||||
|
'map.filter.ship_warning': 'Cảnh báo',
|
||||||
|
'map.filter.ship_warning_placeholder': 'Chọn loại cảnh báo',
|
||||||
|
'map.filter.area_type': 'Loại khu vực',
|
||||||
|
};
|
||||||
@@ -2,6 +2,10 @@ 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.sgw.ships': 'Quản lý tàu',
|
||||||
'menu.manager.sgw.fishes': 'Loài cá',
|
'menu.sgw.fishes': 'Loài cá',
|
||||||
'menu.manager.sgw.zones': 'Khu vực',
|
'menu.sgw.zones': 'Khu vực cấm',
|
||||||
|
|
||||||
|
// Manager menu
|
||||||
|
'menu.manager.sgw.fishes': 'Quản lý loài cá',
|
||||||
|
'menu.manager.sgw.zones': 'Quản lý khu vực cấm',
|
||||||
};
|
};
|
||||||
|
|||||||
16
src/locales/vi-VN/slave/sgw/sgw-photo-vi.ts
Normal file
16
src/locales/vi-VN/slave/sgw/sgw-photo-vi.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export default {
|
||||||
|
'photo.main': 'Ảnh chính',
|
||||||
|
'photo.sub': 'Ảnh phụ',
|
||||||
|
'photo.upload': 'Tải ảnh lên',
|
||||||
|
'photo.delete': 'Xóa ảnh',
|
||||||
|
'photo.delete.confirm': 'Bạn có chắc chắn muốn xóa ảnh này không?',
|
||||||
|
'photo.delete.success': 'Xóa ảnh thành công',
|
||||||
|
'photo.delete.fail': 'Xóa ảnh thất bại',
|
||||||
|
'photo.upload.success': 'Tải ảnh lên thành công',
|
||||||
|
'photo.upload.fail': 'Tải ảnh lên thất bại',
|
||||||
|
'photo.upload.limit': 'Kích thước ảnh không được vượt quá 5MB',
|
||||||
|
'photo.upload.format': 'Chỉ hỗ trợ định dạng JPG/PNG',
|
||||||
|
'photo.manage': 'Quản lý ảnh',
|
||||||
|
'photo.change': 'Thay đổi ảnh',
|
||||||
|
'photo.add': 'Thêm ảnh',
|
||||||
|
};
|
||||||
16
src/locales/vi-VN/slave/sgw/sgw-ship-vi.ts
Normal file
16
src/locales/vi-VN/slave/sgw/sgw-ship-vi.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export default {
|
||||||
|
// Pages - Ship List
|
||||||
|
'pages.ships.reg_number': 'Số đăng ký',
|
||||||
|
'pages.ships.name': 'Tên tàu',
|
||||||
|
'pages.ships.type': 'Loại tàu',
|
||||||
|
'pages.ships.home_port': 'Cảng đăng ký',
|
||||||
|
'pages.ships.option': 'Tùy chọn',
|
||||||
|
|
||||||
|
// Pages - Ship Create/Edit
|
||||||
|
'pages.ship.create.text': 'Tạo tàu mới',
|
||||||
|
'pages.ships.create.title': 'Thêm tàu mới',
|
||||||
|
'pages.ships.edit.title': 'Chỉnh sửa tàu',
|
||||||
|
|
||||||
|
// Pages - Things (Ship related)
|
||||||
|
'pages.things.fishing_license_expiry_date': 'Ngày hết hạn giấy phép',
|
||||||
|
};
|
||||||
64
src/locales/vi-VN/slave/sgw/sgw-trip-vi.ts
Normal file
64
src/locales/vi-VN/slave/sgw/sgw-trip-vi.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
export default {
|
||||||
|
// Pages - Trip List
|
||||||
|
'pages.trips.name': 'Tên chuyến đi',
|
||||||
|
'pages.trips.ship_id': 'Tàu',
|
||||||
|
'pages.trips.departure_time': 'Thời gian khởi hành',
|
||||||
|
'pages.trips.arrival_time': 'Thời gian về',
|
||||||
|
'pages.trips.status': 'Trạng thái',
|
||||||
|
'pages.trips.status.created': 'Đã tạo',
|
||||||
|
'pages.trips.status.pending_approval': 'Chờ phê duyệt',
|
||||||
|
'pages.trips.status.approved': 'Đã phê duyệt',
|
||||||
|
'pages.trips.status.active': 'Đang hoạt động',
|
||||||
|
'pages.trips.status.completed': 'Hoàn thành',
|
||||||
|
'pages.trips.status.cancelled': 'Đã hủy',
|
||||||
|
|
||||||
|
// Pages - Date filters
|
||||||
|
'pages.date.yesterday': 'Hôm qua',
|
||||||
|
'pages.date.lastweek': 'Tuần trước',
|
||||||
|
'pages.date.lastmonth': 'Tháng trước',
|
||||||
|
|
||||||
|
// Pages - Things/Ship
|
||||||
|
'pages.things.createTrip.text': 'Tạo chuyến đi',
|
||||||
|
'pages.things.option': 'Tùy chọn',
|
||||||
|
|
||||||
|
// Trip badges
|
||||||
|
'trip.badge.active': 'Đang hoạt động',
|
||||||
|
'trip.badge.approved': 'Đã duyệt',
|
||||||
|
'trip.badge.cancelled': 'Đã hủy',
|
||||||
|
'trip.badge.completed': 'Hoàn thành',
|
||||||
|
'trip.badge.notApproved': 'Chưa duyệt',
|
||||||
|
'trip.badge.unknown': 'Không xác định',
|
||||||
|
'trip.badge.waitingApproval': 'Chờ duyệt',
|
||||||
|
|
||||||
|
// Cancel trip
|
||||||
|
'trip.cancelTrip.button': 'Hủy chuyến',
|
||||||
|
'trip.cancelTrip.placeholder': 'Nhập lý do hủy chuyến',
|
||||||
|
'trip.cancelTrip.reason': 'Lý do',
|
||||||
|
'trip.cancelTrip.title': 'Hủy chuyến đi',
|
||||||
|
'trip.cancelTrip.validation': 'Vui lòng nhập lý do',
|
||||||
|
|
||||||
|
// Trip cost
|
||||||
|
'trip.cost.amount': 'Số lượng',
|
||||||
|
'trip.cost.crewSalary': 'Lương thuyền viên',
|
||||||
|
'trip.cost.food': 'Thực phẩm',
|
||||||
|
'trip.cost.fuel': 'Nhiên liệu',
|
||||||
|
'trip.cost.grandTotal': 'Tổng cộng',
|
||||||
|
'trip.cost.iceSalt': 'Đá & Muối',
|
||||||
|
'trip.cost.price': 'Giá',
|
||||||
|
'trip.cost.total': 'Tổng',
|
||||||
|
'trip.cost.type': 'Loại',
|
||||||
|
'trip.cost.unit': 'Đơn vị',
|
||||||
|
|
||||||
|
// Trip gear
|
||||||
|
'trip.gear.name': 'Tên ngư cụ',
|
||||||
|
'trip.gear.quantity': 'Số lượng',
|
||||||
|
|
||||||
|
// Haul fish list
|
||||||
|
'trip.haulFishList.fishCondition': 'Tình trạng',
|
||||||
|
'trip.haulFishList.fishName': 'Tên cá',
|
||||||
|
'trip.haulFishList.fishRarity': 'Độ hiếm',
|
||||||
|
'trip.haulFishList.fishSize': 'Kích thước',
|
||||||
|
'trip.haulFishList.gearUsage': 'Ngư cụ sử dụng',
|
||||||
|
'trip.haulFishList.title': 'Danh sách đánh bắt',
|
||||||
|
'trip.haulFishList.weight': 'Trọng lượng',
|
||||||
|
};
|
||||||
@@ -1,6 +1,19 @@
|
|||||||
|
import sgwFish from './sgw-fish-vi';
|
||||||
|
import sgwMap from './sgw-map-vi';
|
||||||
import sgwMenu from './sgw-menu-vi';
|
import sgwMenu from './sgw-menu-vi';
|
||||||
|
import sgwPhoto from './sgw-photo-vi';
|
||||||
|
import sgwShip from './sgw-ship-vi';
|
||||||
|
import sgwTrip from './sgw-trip-vi';
|
||||||
|
import sgwZone from './sgw-zone-vi';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
'sgw.title': 'Hệ thống giám sát tàu cá',
|
'sgw.title': 'Hệ thống giám sát tàu cá',
|
||||||
'sgw.ship': 'Tàu',
|
'sgw.ship': 'Tàu',
|
||||||
...sgwMenu,
|
...sgwMenu,
|
||||||
|
...sgwTrip,
|
||||||
|
...sgwMap,
|
||||||
|
...sgwShip,
|
||||||
|
...sgwFish,
|
||||||
|
...sgwPhoto,
|
||||||
|
...sgwZone,
|
||||||
};
|
};
|
||||||
|
|||||||
31
src/locales/vi-VN/slave/sgw/sgw-zone-vi.ts
Normal file
31
src/locales/vi-VN/slave/sgw/sgw-zone-vi.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
export default {
|
||||||
|
// Table columns
|
||||||
|
'banzones.name': 'Tên khu vực',
|
||||||
|
'banzones.area': 'Tỉnh/Thành phố',
|
||||||
|
'banzones.description': 'Mô tả',
|
||||||
|
'banzones.type': 'Loại',
|
||||||
|
'banzones.conditions': 'Điều kiện',
|
||||||
|
'banzones.state': 'Trạng thái',
|
||||||
|
'banzones.action': 'Hành động',
|
||||||
|
'banzones.title': 'khu vực',
|
||||||
|
'banzones.create': 'Tạo khu vực',
|
||||||
|
|
||||||
|
// Zone types
|
||||||
|
'banzone.area.fishing_ban': 'Cấm khai thác',
|
||||||
|
'banzone.area.move_ban': 'Cấm di chuyển',
|
||||||
|
'banzone.area.safe': 'Khu vực an toàn',
|
||||||
|
|
||||||
|
// Status
|
||||||
|
'banzone.is_enable': 'Kích hoạt',
|
||||||
|
'banzone.is_unenabled': 'Vô hiệu hóa',
|
||||||
|
|
||||||
|
// Shape types
|
||||||
|
'banzone.polygon': 'Đa giác',
|
||||||
|
'banzone.polyline': 'Đường kẻ',
|
||||||
|
'banzone.circle': 'Hình tròn',
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
'banzone.notify.delete_zone_success': 'Xóa khu vực thành công',
|
||||||
|
'banzone.notify.delete_zone_confirm': 'Bạn có chắc chắn muốn xóa khu vực',
|
||||||
|
'banzone.notify.fail': 'Thao tác thất bại!',
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SHIP_SOS_WS_URL } from '@/constants/slave/sgw/websocket';
|
import { SHIP_SOS_WS_URL } from '@/constants/slave/sgw/websocket';
|
||||||
import { wsClient } from '@/utils/slave/sgw/wsClient';
|
import { wsClient } from '@/utils/wsClient';
|
||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
|
|
||||||
export default function useGetShipSos() {
|
export default function useGetShipSos() {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
FilterOutlined,
|
FilterOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
|
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
|
||||||
import { Button, Flex, message, Popconfirm, Progress, Tooltip } from 'antd';
|
import { Button, Flex, message, Popconfirm, Progress, Tooltip } from 'antd';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
@@ -24,6 +24,9 @@ const AlarmPage = () => {
|
|||||||
const [thingFilterDatas, setThingFilterDatas] = useState<string[]>([]);
|
const [thingFilterDatas, setThingFilterDatas] = useState<string[]>([]);
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
const { initialState } = useModel('@@initialState');
|
||||||
|
const { currentUserProfile } = initialState || {};
|
||||||
|
|
||||||
const columns: ProColumns<MasterModel.Alarm>[] = [
|
const columns: ProColumns<MasterModel.Alarm>[] = [
|
||||||
{
|
{
|
||||||
title: intl.formatMessage({
|
title: intl.formatMessage({
|
||||||
|
|||||||
505
src/pages/Manager/Device/Camera/index.tsx
Normal file
505
src/pages/Manager/Device/Camera/index.tsx
Normal file
@@ -0,0 +1,505 @@
|
|||||||
|
import { apiSearchThings } from '@/services/master/ThingController';
|
||||||
|
import { wsClient } from '@/utils/wsClient';
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { PageContainer } from '@ant-design/pro-components';
|
||||||
|
import { history, useParams } from '@umijs/max';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Checkbox,
|
||||||
|
Col,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
|
Modal,
|
||||||
|
Row,
|
||||||
|
Select,
|
||||||
|
Space,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
theme,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
// Camera types
|
||||||
|
const CAMERA_TYPES = [
|
||||||
|
{ label: 'HIKVISION', value: 'HIKVISION' },
|
||||||
|
{ label: 'DAHUA', value: 'DAHUA' },
|
||||||
|
{ label: 'GENERIC', value: 'GENERIC' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Recording modes
|
||||||
|
const RECORDING_MODES = [
|
||||||
|
{ label: 'Theo cảnh báo', value: 'alarm' },
|
||||||
|
{ label: 'Liên tục', value: 'continuous' },
|
||||||
|
{ label: 'Thủ công', value: 'manual' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Alert types for configuration
|
||||||
|
const ALERT_TYPES = [
|
||||||
|
{ id: 'motion', name: 'Chuyển Động có cảnh báo' },
|
||||||
|
{ id: 'smoke', name: 'Khói có cảnh báo' },
|
||||||
|
{ id: 'door', name: 'Cửa có cảnh báo' },
|
||||||
|
{ id: 'ac1_high', name: 'Điện AC 1 cao' },
|
||||||
|
{ id: 'ac1_low', name: 'Điện AC 1 thấp' },
|
||||||
|
{ id: 'ac1_lost', name: 'Điện AC 1 mất' },
|
||||||
|
{ id: 'load_high', name: 'Điện tải cao' },
|
||||||
|
{ id: 'load_low', name: 'Điện tải thấp' },
|
||||||
|
{ id: 'load_lost', name: 'Điện tải mất' },
|
||||||
|
{ id: 'grid_high', name: 'Điện lưới cao' },
|
||||||
|
{ id: 'grid_low', name: 'Điện lưới thấp' },
|
||||||
|
{ id: 'grid_lost', name: 'Điện lưới mất' },
|
||||||
|
{ id: 'ac1_on_error', name: 'Điều hòa 1 bật lỗi' },
|
||||||
|
{ id: 'ac1_off_error', name: 'Điều hòa 1 tắt lỗi' },
|
||||||
|
{ id: 'ac1_has_error', name: 'Điều hòa 1 có thể lỗi' },
|
||||||
|
{ id: 'ac2_on_error', name: 'Điều hòa 2 bật lỗi' },
|
||||||
|
{ id: 'ac2_off_error', name: 'Điều hòa 2 tắt lỗi' },
|
||||||
|
{ id: 'ac2_has_error', name: 'Điều hòa 2 điều hòa có thể lỗi' },
|
||||||
|
{ id: 'room_temp_high', name: 'Nhiệt độ phòng máy nhiệt độ phòng máy cao' },
|
||||||
|
{ id: 'rectifier_error', name: 'Rectifier bật lỗi' },
|
||||||
|
{ id: 'meter_volt_high', name: 'Công tơ điện điện áp cao' },
|
||||||
|
{ id: 'meter_volt_low', name: 'Công tơ điện điện áp thấp' },
|
||||||
|
{ id: 'meter_lost', name: 'Công tơ điện mất điện áp' },
|
||||||
|
{ id: 'lithium_volt_low', name: 'Pin lithium điện áp thấp' },
|
||||||
|
{ id: 'lithium_temp_high', name: 'Pin lithium nhiệt độ cao' },
|
||||||
|
{ id: 'lithium_capacity_low', name: 'Pin lithium dung lượng thấp' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Camera interface
|
||||||
|
interface Camera {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
ipAddress: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CameraFormValues {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
account: string;
|
||||||
|
password: string;
|
||||||
|
ipAddress: string;
|
||||||
|
rtspPort: number;
|
||||||
|
httpPort: number;
|
||||||
|
stream: number;
|
||||||
|
channel: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CameraConfigPage = () => {
|
||||||
|
const { thingId } = useParams<{ thingId: string }>();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const [form] = Form.useForm<CameraFormValues>();
|
||||||
|
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||||
|
const [cameras, setCameras] = useState<Camera[]>([]);
|
||||||
|
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([
|
||||||
|
'motion',
|
||||||
|
'smoke',
|
||||||
|
'door',
|
||||||
|
]);
|
||||||
|
const [recordingMode, setRecordingMode] = useState('alarm');
|
||||||
|
const [thingName, setThingName] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
wsClient.connect('wss://gms.smatec.com.vn/mqtt', false);
|
||||||
|
const unsubscribe = wsClient.subscribe((data: any) => {
|
||||||
|
console.log('Received WS data:', data);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch thing info on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchThingInfo = async () => {
|
||||||
|
if (!thingId) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await apiSearchThings({
|
||||||
|
offset: 0,
|
||||||
|
limit: 1,
|
||||||
|
id: thingId,
|
||||||
|
});
|
||||||
|
if (response?.things && response.things.length > 0) {
|
||||||
|
setThingName(response.things[0].name || thingId);
|
||||||
|
} else {
|
||||||
|
setThingName(thingId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch thing info:', error);
|
||||||
|
setThingName(thingId);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchThingInfo();
|
||||||
|
}, [thingId]);
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
history.push('/manager/devices');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenModal = () => {
|
||||||
|
form.resetFields();
|
||||||
|
form.setFieldsValue({
|
||||||
|
type: 'HIKVISION',
|
||||||
|
rtspPort: 554,
|
||||||
|
httpPort: 80,
|
||||||
|
stream: 0,
|
||||||
|
channel: 0,
|
||||||
|
});
|
||||||
|
setIsModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsModalVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitCamera = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
console.log('Camera values:', values);
|
||||||
|
// TODO: Call API to create camera
|
||||||
|
setCameras([
|
||||||
|
...cameras,
|
||||||
|
{
|
||||||
|
id: String(cameras.length + 1),
|
||||||
|
name: values.name,
|
||||||
|
type: values.type,
|
||||||
|
ipAddress: values.ipAddress,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
handleCloseModal();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Validation failed:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAlertToggle = (alertId: string) => {
|
||||||
|
if (selectedAlerts.includes(alertId)) {
|
||||||
|
setSelectedAlerts(selectedAlerts.filter((id) => id !== alertId));
|
||||||
|
} else {
|
||||||
|
setSelectedAlerts([...selectedAlerts, alertId]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAlerts = () => {
|
||||||
|
setSelectedAlerts([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmitAlerts = () => {
|
||||||
|
console.log('Submit alerts:', selectedAlerts);
|
||||||
|
// TODO: Call API to save alert configuration
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
dataIndex: 'checkbox',
|
||||||
|
width: 50,
|
||||||
|
render: () => <Checkbox />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Tên',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (text: string) => (
|
||||||
|
<a style={{ color: token.colorPrimary }}>{text}</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Loại',
|
||||||
|
dataIndex: 'type',
|
||||||
|
key: 'type',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Địa chỉ IP',
|
||||||
|
dataIndex: 'ipAddress',
|
||||||
|
key: 'ipAddress',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Thao tác',
|
||||||
|
key: 'action',
|
||||||
|
render: () => <Button size="small" icon={<EditOutlined />} />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Spin spinning={loading}>
|
||||||
|
<PageContainer
|
||||||
|
header={{
|
||||||
|
title: (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
icon={<ArrowLeftOutlined />}
|
||||||
|
onClick={handleBack}
|
||||||
|
/>
|
||||||
|
<span>{thingName || 'Loading...'}</span>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Row gutter={24}>
|
||||||
|
{/* Left Column - Camera Table */}
|
||||||
|
<Col xs={24} md={10} lg={8}>
|
||||||
|
<Card bodyStyle={{ padding: 16 }}>
|
||||||
|
<Space style={{ marginBottom: 16 }}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleOpenModal}
|
||||||
|
>
|
||||||
|
Tạo mới camera
|
||||||
|
</Button>
|
||||||
|
<Button icon={<ReloadOutlined />} />
|
||||||
|
<Button icon={<SettingOutlined />} />
|
||||||
|
<Button icon={<DeleteOutlined />} />
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Table
|
||||||
|
dataSource={cameras}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
size="small"
|
||||||
|
pagination={{
|
||||||
|
size: 'small',
|
||||||
|
showTotal: (total, range) =>
|
||||||
|
`${range[0]}-${range[1]} trên ${total} mặt hàng`,
|
||||||
|
pageSize: 10,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* Right Column - Alert Configuration */}
|
||||||
|
<Col xs={24} md={14} lg={16}>
|
||||||
|
<Card bodyStyle={{ padding: 16 }}>
|
||||||
|
{/* Recording Mode */}
|
||||||
|
<div style={{ marginBottom: 24 }}>
|
||||||
|
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
Ghi dữ liệu camera
|
||||||
|
</Text>
|
||||||
|
<Select
|
||||||
|
value={recordingMode}
|
||||||
|
onChange={setRecordingMode}
|
||||||
|
options={RECORDING_MODES}
|
||||||
|
style={{ width: 200 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alert List */}
|
||||||
|
<div>
|
||||||
|
<Text strong style={{ display: 'block', marginBottom: 8 }}>
|
||||||
|
Danh sách cảnh báo
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: '8px 12px',
|
||||||
|
background: token.colorBgContainer,
|
||||||
|
borderRadius: token.borderRadius,
|
||||||
|
border: `1px solid ${token.colorBorder}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text type="secondary">
|
||||||
|
đã chọn {selectedAlerts.length} mục
|
||||||
|
</Text>
|
||||||
|
<Button type="link" onClick={handleClearAlerts}>
|
||||||
|
Xóa
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alert Cards Grid */}
|
||||||
|
<Row gutter={[12, 12]}>
|
||||||
|
{ALERT_TYPES.map((alert) => {
|
||||||
|
const isSelected = selectedAlerts.includes(alert.id);
|
||||||
|
return (
|
||||||
|
<Col xs={12} sm={8} md={6} lg={4} xl={4} key={alert.id}>
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
hoverable
|
||||||
|
onClick={() => handleAlertToggle(alert.id)}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderColor: isSelected
|
||||||
|
? token.colorPrimary
|
||||||
|
: token.colorBorder,
|
||||||
|
borderWidth: isSelected ? 2 : 1,
|
||||||
|
background: isSelected
|
||||||
|
? token.colorPrimaryBg
|
||||||
|
: token.colorBgContainer,
|
||||||
|
height: 80,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
bodyStyle={{
|
||||||
|
padding: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
fontSize: 12,
|
||||||
|
color: isSelected
|
||||||
|
? token.colorPrimary
|
||||||
|
: token.colorText,
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{alert.name}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div style={{ marginTop: 24, textAlign: 'center' }}>
|
||||||
|
<Button type="primary" onClick={handleSubmitAlerts}>
|
||||||
|
Gửi đi
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Create Camera Modal */}
|
||||||
|
<Modal
|
||||||
|
title="Tạo mới"
|
||||||
|
open={isModalVisible}
|
||||||
|
onCancel={handleCloseModal}
|
||||||
|
footer={[
|
||||||
|
<Button key="cancel" onClick={handleCloseModal}>
|
||||||
|
Hủy
|
||||||
|
</Button>,
|
||||||
|
<Button key="submit" type="primary" onClick={handleSubmitCamera}>
|
||||||
|
Đồng ý
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={{
|
||||||
|
type: 'HIKVISION',
|
||||||
|
rtspPort: 554,
|
||||||
|
httpPort: 80,
|
||||||
|
stream: 0,
|
||||||
|
channel: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="Tên"
|
||||||
|
name="name"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập tên' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="nhập dữ liệu" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Loại"
|
||||||
|
name="type"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng chọn loại' }]}
|
||||||
|
>
|
||||||
|
<Select options={CAMERA_TYPES} />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Tài khoản"
|
||||||
|
name="account"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập tài khoản' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="nhập tài khoản" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Mật khẩu"
|
||||||
|
name="password"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập mật khẩu' }]}
|
||||||
|
>
|
||||||
|
<Input.Password placeholder="nhập mật khẩu" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
label="Địa chỉ IP"
|
||||||
|
name="ipAddress"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập địa chỉ IP' }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="192.168.1.10" />
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
label="Cổng RTSP"
|
||||||
|
name="rtspPort"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: 'Vui lòng nhập cổng RTSP' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
label="Cổng HTTP"
|
||||||
|
name="httpPort"
|
||||||
|
rules={[
|
||||||
|
{ required: true, message: 'Vui lòng nhập cổng HTTP' },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} max={65535} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
label="Luồng"
|
||||||
|
name="stream"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập luồng' }]}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<Form.Item
|
||||||
|
label="Kênh"
|
||||||
|
name="channel"
|
||||||
|
rules={[{ required: true, message: 'Vui lòng nhập kênh' }]}
|
||||||
|
>
|
||||||
|
<InputNumber style={{ width: '100%' }} min={0} />
|
||||||
|
</Form.Item>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</PageContainer>
|
||||||
|
</Spin>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CameraConfigPage;
|
||||||
@@ -6,11 +6,7 @@ interface Props {
|
|||||||
visible: boolean;
|
visible: boolean;
|
||||||
device: MasterModel.Thing | null;
|
device: MasterModel.Thing | null;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onSubmit: (values: {
|
onSubmit: (values: MasterModel.Thing) => void;
|
||||||
name: string;
|
|
||||||
external_id: string;
|
|
||||||
address?: string;
|
|
||||||
}) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EditDeviceModal: React.FC<Props> = ({
|
const EditDeviceModal: React.FC<Props> = ({
|
||||||
@@ -34,6 +30,24 @@ const EditDeviceModal: React.FC<Props> = ({
|
|||||||
}
|
}
|
||||||
}, [device, form]);
|
}, [device, form]);
|
||||||
|
|
||||||
|
const handleFinish = (values: {
|
||||||
|
name: string;
|
||||||
|
external_id: string;
|
||||||
|
address?: string;
|
||||||
|
}) => {
|
||||||
|
const payload: MasterModel.Thing = {
|
||||||
|
...device,
|
||||||
|
name: values.name,
|
||||||
|
metadata: {
|
||||||
|
...(device?.metadata || {}),
|
||||||
|
external_id: values.external_id,
|
||||||
|
address: values.address,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
onSubmit(payload);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={intl.formatMessage({
|
title={intl.formatMessage({
|
||||||
@@ -53,7 +67,12 @@ const EditDeviceModal: React.FC<Props> = ({
|
|||||||
})}
|
})}
|
||||||
destroyOnClose
|
destroyOnClose
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" onFinish={onSubmit} preserve={false}>
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleFinish}
|
||||||
|
preserve={false}
|
||||||
|
>
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="name"
|
name="name"
|
||||||
label={intl.formatMessage({
|
label={intl.formatMessage({
|
||||||
|
|||||||
121
src/pages/Manager/Device/components/LocationModal.tsx
Normal file
121
src/pages/Manager/Device/components/LocationModal.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Form, Input, Modal } from 'antd';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean;
|
||||||
|
device: MasterModel.Thing | null;
|
||||||
|
onCancel: () => void;
|
||||||
|
onSubmit: (values: MasterModel.Thing) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocationModal: React.FC<Props> = ({
|
||||||
|
visible,
|
||||||
|
device,
|
||||||
|
onCancel,
|
||||||
|
onSubmit,
|
||||||
|
}) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const intl = useIntl();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (device) {
|
||||||
|
form.setFieldsValue({
|
||||||
|
lat: device?.metadata?.lat || '',
|
||||||
|
lng: device?.metadata?.lng || '',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
form.resetFields();
|
||||||
|
}
|
||||||
|
}, [device, form]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'master.devices.location.title',
|
||||||
|
defaultMessage: 'Update location',
|
||||||
|
})}
|
||||||
|
open={visible}
|
||||||
|
onCancel={onCancel}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
okText={intl.formatMessage({
|
||||||
|
id: 'master.devices.ok',
|
||||||
|
defaultMessage: 'OK',
|
||||||
|
})}
|
||||||
|
cancelText={intl.formatMessage({
|
||||||
|
id: 'master.devices.cancel',
|
||||||
|
defaultMessage: 'Cancel',
|
||||||
|
})}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={(values) => {
|
||||||
|
const payload: MasterModel.Thing = {
|
||||||
|
id: device?.id,
|
||||||
|
name: device?.name,
|
||||||
|
key: device?.key,
|
||||||
|
metadata: {
|
||||||
|
...device?.metadata,
|
||||||
|
lat: values.lat,
|
||||||
|
lng: values.lng,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
onSubmit(payload);
|
||||||
|
}}
|
||||||
|
preserve={false}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
name="lat"
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'master.devices.location.latitude',
|
||||||
|
defaultMessage: 'Latitude',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'master.devices.location.latitude.required',
|
||||||
|
defaultMessage: 'Please enter latitude',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'master.devices.location.placeholder',
|
||||||
|
defaultMessage: 'Enter data',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
<Form.Item
|
||||||
|
name="lng"
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'master.devices.location.longitude',
|
||||||
|
defaultMessage: 'Longitude',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'master.devices.location.longitude.required',
|
||||||
|
defaultMessage: 'Please enter longitude',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'master.devices.location.placeholder',
|
||||||
|
defaultMessage: 'Enter data',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LocationModal;
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import IconFont from '@/components/IconFont';
|
import IconFont from '@/components/IconFont';
|
||||||
import TreeGroup from '@/components/shared/TreeGroup';
|
import TreeGroup from '@/components/shared/TreeGroup';
|
||||||
import { DEFAULT_PAGE_SIZE } from '@/constants';
|
import { DEFAULT_PAGE_SIZE } from '@/constants';
|
||||||
import { apiSearchThings } from '@/services/master/ThingController';
|
import {
|
||||||
|
apiSearchThings,
|
||||||
|
apiUpdateThing,
|
||||||
|
} from '@/services/master/ThingController';
|
||||||
import { EditOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
import { EditOutlined, EnvironmentOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
ActionType,
|
ActionType,
|
||||||
@@ -9,13 +12,14 @@ import {
|
|||||||
ProColumns,
|
ProColumns,
|
||||||
ProTable,
|
ProTable,
|
||||||
} from '@ant-design/pro-components';
|
} from '@ant-design/pro-components';
|
||||||
import { FormattedMessage, useIntl } from '@umijs/max';
|
import { FormattedMessage, history, useIntl } from '@umijs/max';
|
||||||
import { Button, Divider, Grid, Space, Tag, theme } from 'antd';
|
import { Button, Divider, Grid, Space, Tag, theme } from 'antd';
|
||||||
import message from 'antd/es/message';
|
import message from 'antd/es/message';
|
||||||
import Paragraph from 'antd/lib/typography/Paragraph';
|
import Paragraph from 'antd/lib/typography/Paragraph';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import CreateDevice from './components/CreateDevice';
|
import CreateDevice from './components/CreateDevice';
|
||||||
import EditDeviceModal from './components/EditDeviceModal';
|
import EditDeviceModal from './components/EditDeviceModal';
|
||||||
|
import LocationModal from './components/LocationModal';
|
||||||
|
|
||||||
const ManagerDevicePage = () => {
|
const ManagerDevicePage = () => {
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
@@ -35,9 +39,14 @@ const ManagerDevicePage = () => {
|
|||||||
const [editingDevice, setEditingDevice] = useState<MasterModel.Thing | null>(
|
const [editingDevice, setEditingDevice] = useState<MasterModel.Thing | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [isLocationModalVisible, setIsLocationModalVisible] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
const [locationDevice, setLocationDevice] =
|
||||||
|
useState<MasterModel.Thing | null>(null);
|
||||||
|
|
||||||
const handleClickAssign = (device: MasterModel.Thing) => {
|
const handleLocation = (device: MasterModel.Thing) => {
|
||||||
console.log('Device ', device);
|
setLocationDevice(device);
|
||||||
|
setIsLocationModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (device: MasterModel.Thing) => {
|
const handleEdit = (device: MasterModel.Thing) => {
|
||||||
@@ -50,13 +59,53 @@ const ManagerDevicePage = () => {
|
|||||||
setEditingDevice(null);
|
setEditingDevice(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditSubmit = async (values: any) => {
|
const handleLocationCancel = () => {
|
||||||
// TODO: call update API here if available. For now just simulate success.
|
setIsLocationModalVisible(false);
|
||||||
console.log('Update values for', editingDevice?.id, values);
|
setLocationDevice(null);
|
||||||
messageApi.success('Cập nhật thành công');
|
};
|
||||||
|
|
||||||
|
const handleLocationSubmit = async (values: MasterModel.Thing) => {
|
||||||
|
try {
|
||||||
|
await apiUpdateThing(values);
|
||||||
|
messageApi.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.location.update.success',
|
||||||
|
defaultMessage: 'Location updated successfully',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setIsLocationModalVisible(false);
|
||||||
|
setLocationDevice(null);
|
||||||
|
actionRef.current?.reload();
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.location.update.error',
|
||||||
|
defaultMessage: 'Location update failed',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditSubmit = async (values: MasterModel.Thing) => {
|
||||||
|
try {
|
||||||
|
await apiUpdateThing(values);
|
||||||
|
messageApi.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.update.success',
|
||||||
|
defaultMessage: 'Updated successfully',
|
||||||
|
}),
|
||||||
|
);
|
||||||
setIsEditModalVisible(false);
|
setIsEditModalVisible(false);
|
||||||
setEditingDevice(null);
|
setEditingDevice(null);
|
||||||
actionRef.current?.reload();
|
actionRef.current?.reload();
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'master.devices.update.error',
|
||||||
|
defaultMessage: 'Update failed',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ProColumns<MasterModel.Thing>[] = [
|
const columns: ProColumns<MasterModel.Thing>[] = [
|
||||||
@@ -172,20 +221,21 @@ const ManagerDevicePage = () => {
|
|||||||
shape="default"
|
shape="default"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<EnvironmentOutlined />}
|
icon={<EnvironmentOutlined />}
|
||||||
// onClick={() => handleClickAssign(device)}
|
onClick={() => handleLocation(device)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
shape="default"
|
shape="default"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconFont type="icon-camera" />}
|
icon={<IconFont type="icon-camera" />}
|
||||||
// onClick={() => handleClickAssign(device)}
|
onClick={() => {
|
||||||
|
history.push(`/manager/devices/${device.id}/camera`);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
{device?.metadata?.type === 'gmsv6' && (
|
{device?.metadata?.type === 'gmsv6' && (
|
||||||
<Button
|
<Button
|
||||||
shape="default"
|
shape="default"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconFont type="icon-terminal" />}
|
icon={<IconFont type="icon-terminal" />}
|
||||||
// onClick={() => handleClickAssign(device)}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
@@ -202,6 +252,12 @@ const ManagerDevicePage = () => {
|
|||||||
onCancel={handleEditCancel}
|
onCancel={handleEditCancel}
|
||||||
onSubmit={handleEditSubmit}
|
onSubmit={handleEditSubmit}
|
||||||
/>
|
/>
|
||||||
|
<LocationModal
|
||||||
|
visible={isLocationModalVisible}
|
||||||
|
device={locationDevice}
|
||||||
|
onCancel={handleLocationCancel}
|
||||||
|
onSubmit={handleLocationSubmit}
|
||||||
|
/>
|
||||||
{contextHolder}
|
{contextHolder}
|
||||||
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
||||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 6, lg: 6, xl: 6 }}>
|
<ProCard colSpan={{ xs: 24, sm: 24, md: 6, lg: 6, xl: 6 }}>
|
||||||
@@ -255,7 +311,7 @@ const ManagerDevicePage = () => {
|
|||||||
const offset = current === 1 ? 0 : (current - 1) * size;
|
const offset = current === 1 ? 0 : (current - 1) * size;
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
const metadata: Partial<MasterModel.ThingMetadata> = {};
|
const metadata: Partial<MasterModel.SearchThingMetadata> = {};
|
||||||
if (external_id) metadata.external_id = external_id;
|
if (external_id) metadata.external_id = external_id;
|
||||||
|
|
||||||
// Add group filter if groups are selected
|
// Add group filter if groups are selected
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const SystemLogs = () => {
|
|||||||
const tableRef = useRef<ActionType>();
|
const tableRef = useRef<ActionType>();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
|
|
||||||
const queryUserSource = async (): Promise<MasterModel.ProfileResponse[]> => {
|
const queryUserSource = async (): Promise<MasterModel.UserResponse[]> => {
|
||||||
try {
|
try {
|
||||||
const body: MasterModel.SearchUserPaginationBody = {
|
const body: MasterModel.SearchUserPaginationBody = {
|
||||||
offset: 0,
|
offset: 0,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ enum AssignTabsKey {
|
|||||||
const AssignUserPage = () => {
|
const AssignUserPage = () => {
|
||||||
const { userId } = useParams<{ userId: string }>();
|
const { userId } = useParams<{ userId: string }>();
|
||||||
const [userProfile, setUserProfile] =
|
const [userProfile, setUserProfile] =
|
||||||
useState<MasterModel.ProfileResponse | null>(null);
|
useState<MasterModel.UserResponse | null>(null);
|
||||||
const [loading, setLoading] = useState<boolean>(false);
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
const [tabSelected, setTabSelected] = useState<AssignTabsKey>(
|
const [tabSelected, setTabSelected] = useState<AssignTabsKey>(
|
||||||
AssignTabsKey.group,
|
AssignTabsKey.group,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
type AssignGroupProps = {
|
type AssignGroupProps = {
|
||||||
user: MasterModel.ProfileResponse | null;
|
user: MasterModel.UserResponse | null;
|
||||||
};
|
};
|
||||||
const AssignGroup = ({ user }: AssignGroupProps) => {
|
const AssignGroup = ({ user }: AssignGroupProps) => {
|
||||||
const groupActionRef = useRef<ActionType>();
|
const groupActionRef = useRef<ActionType>();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ type PolicyShareDefault = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ShareThingProps = {
|
type ShareThingProps = {
|
||||||
user: MasterModel.ProfileResponse | null;
|
user: MasterModel.UserResponse | null;
|
||||||
};
|
};
|
||||||
const ShareThing = ({ user }: ShareThingProps) => {
|
const ShareThing = ({ user }: ShareThingProps) => {
|
||||||
const listActionRef = useRef<ActionType>();
|
const listActionRef = useRef<ActionType>();
|
||||||
|
|||||||
@@ -34,19 +34,19 @@ const ManagerUserPage = () => {
|
|||||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
const [messageApi, contextHolder] = message.useMessage();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
const [selectedRowsState, setSelectedRowsState] = useState<
|
const [selectedRowsState, setSelectedRowsState] = useState<
|
||||||
MasterModel.ProfileResponse[]
|
MasterModel.UserResponse[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
const [groupCheckedKeys, setGroupCheckedKeys] = useState<
|
const [groupCheckedKeys, setGroupCheckedKeys] = useState<
|
||||||
string | string[] | null
|
string | string[] | null
|
||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
const handleClickAssign = (user: MasterModel.ProfileResponse) => {
|
const handleClickAssign = (user: MasterModel.UserResponse) => {
|
||||||
const path = `${ROUTE_MANAGER_USERS}/${user.id}/${ROUTE_MANAGER_USERS_PERMISSIONS}`;
|
const path = `${ROUTE_MANAGER_USERS}/${user.id}/${ROUTE_MANAGER_USERS_PERMISSIONS}`;
|
||||||
history.push(path);
|
history.push(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns: ProColumns<MasterModel.ProfileResponse>[] = [
|
const columns: ProColumns<MasterModel.UserResponse>[] = [
|
||||||
{
|
{
|
||||||
key: 'email',
|
key: 'email',
|
||||||
title: (
|
title: (
|
||||||
@@ -136,7 +136,7 @@ const ManagerUserPage = () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleRemove = async (selectedRows: MasterModel.ProfileResponse[]) => {
|
const handleRemove = async (selectedRows: MasterModel.UserResponse[]) => {
|
||||||
const key = 'remove_user';
|
const key = 'remove_user';
|
||||||
if (!selectedRows) return true;
|
if (!selectedRows) return true;
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ const ManagerUserPage = () => {
|
|||||||
key,
|
key,
|
||||||
});
|
});
|
||||||
const allDelete = selectedRows.map(
|
const allDelete = selectedRows.map(
|
||||||
async (row: MasterModel.ProfileResponse) => {
|
async (row: MasterModel.UserResponse) => {
|
||||||
await apiDeleteUser(row?.id || '');
|
await apiDeleteUser(row?.id || '');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -196,7 +196,7 @@ const ManagerUserPage = () => {
|
|||||||
/>
|
/>
|
||||||
</ProCard>
|
</ProCard>
|
||||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 18, lg: 18, xl: 18 }}>
|
<ProCard colSpan={{ xs: 24, sm: 24, md: 18, lg: 18, xl: 18 }}>
|
||||||
<ProTable<MasterModel.ProfileResponse>
|
<ProTable<MasterModel.UserResponse>
|
||||||
columns={columns}
|
columns={columns}
|
||||||
tableLayout="auto"
|
tableLayout="auto"
|
||||||
actionRef={actionRef}
|
actionRef={actionRef}
|
||||||
@@ -210,7 +210,7 @@ const ManagerUserPage = () => {
|
|||||||
selectedRowKeys: selectedRowsState.map((row) => row.id!),
|
selectedRowKeys: selectedRowsState.map((row) => row.id!),
|
||||||
onChange: (
|
onChange: (
|
||||||
_: React.Key[],
|
_: React.Key[],
|
||||||
selectedRows: MasterModel.ProfileResponse[],
|
selectedRows: MasterModel.UserResponse[],
|
||||||
) => {
|
) => {
|
||||||
setSelectedRowsState(selectedRows);
|
setSelectedRowsState(selectedRows);
|
||||||
},
|
},
|
||||||
@@ -249,12 +249,12 @@ const ManagerUserPage = () => {
|
|||||||
let users = userByGroupResponses.users || [];
|
let users = userByGroupResponses.users || [];
|
||||||
// Apply filters
|
// Apply filters
|
||||||
if (email) {
|
if (email) {
|
||||||
users = users.filter((user: MasterModel.ProfileResponse) =>
|
users = users.filter((user: MasterModel.UserResponse) =>
|
||||||
user.email?.includes(email),
|
user.email?.includes(email),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (phone_number) {
|
if (phone_number) {
|
||||||
users = users.filter((user: MasterModel.ProfileResponse) =>
|
users = users.filter((user: MasterModel.UserResponse) =>
|
||||||
user.metadata?.phone_number?.includes(phone_number),
|
user.metadata?.phone_number?.includes(phone_number),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -269,7 +269,7 @@ const ManagerUserPage = () => {
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Use regular queryUsers API
|
// Use regular queryUsers API
|
||||||
const metadata: Partial<MasterModel.ProfileMetadata> = {};
|
const metadata: Partial<MasterModel.UserMetadata> = {};
|
||||||
if (phone_number) metadata.phone_number = phone_number;
|
if (phone_number) metadata.phone_number = phone_number;
|
||||||
|
|
||||||
const query: MasterModel.SearchUserPaginationBody = {
|
const query: MasterModel.SearchUserPaginationBody = {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const ChangeProfile = () => {
|
|||||||
}}
|
}}
|
||||||
onFinish={async (values) => {
|
onFinish={async (values) => {
|
||||||
try {
|
try {
|
||||||
const body: Partial<MasterModel.ProfileMetadata> = {
|
const body: Partial<MasterModel.UserMetadata> = {
|
||||||
full_name: values.full_name,
|
full_name: values.full_name,
|
||||||
phone_number: values.phone_number,
|
phone_number: values.phone_number,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { ModalForm } from '@ant-design/pro-components';
|
||||||
|
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||||
|
import { Button, DatePicker, Flex, Form, Input, Select, Space } from 'antd';
|
||||||
|
import { FormListFieldData } from 'antd/lib';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { AreaCondition, ZoneFormField } from '../type';
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
// Transform form values to match AreaCondition type
|
||||||
|
const transformToAreaCondition = (values: any[]): AreaCondition[] => {
|
||||||
|
return values.map((item) => {
|
||||||
|
const { type } = item;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'month_range': {
|
||||||
|
// RangePicker for month returns [Dayjs, Dayjs]
|
||||||
|
const [from, to] = item.from ?? [];
|
||||||
|
return {
|
||||||
|
type: 'month_range',
|
||||||
|
from: from?.month() ?? 0, // 0-11
|
||||||
|
to: to?.month() ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'date_range': {
|
||||||
|
// RangePicker for date returns [Dayjs, Dayjs]
|
||||||
|
const [from, to] = item.from ?? [];
|
||||||
|
return {
|
||||||
|
type: 'date_range',
|
||||||
|
from: from?.format('YYYY-MM-DD') ?? '',
|
||||||
|
to: to?.format('YYYY-MM-DD') ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'length_limit': {
|
||||||
|
return {
|
||||||
|
type: 'length_limit',
|
||||||
|
min: Number(item.min) ?? 0,
|
||||||
|
max: Number(item.max) ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transform AreaCondition to form values
|
||||||
|
const transformToFormValues = (conditions?: AreaCondition[]): any[] => {
|
||||||
|
if (!conditions || conditions.length === 0) return [{}];
|
||||||
|
|
||||||
|
return conditions.map((condition) => {
|
||||||
|
switch (condition.type) {
|
||||||
|
case 'month_range': {
|
||||||
|
return {
|
||||||
|
type: 'month_range',
|
||||||
|
from: [dayjs().month(condition.from), dayjs().month(condition.to)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'date_range': {
|
||||||
|
return {
|
||||||
|
type: 'date_range',
|
||||||
|
from: [dayjs(condition.from), dayjs(condition.to)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'length_limit': {
|
||||||
|
return condition;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ConditionRow = ({
|
||||||
|
field,
|
||||||
|
remove,
|
||||||
|
}: {
|
||||||
|
field: FormListFieldData;
|
||||||
|
remove: (name: number) => void;
|
||||||
|
}) => {
|
||||||
|
const selectedType = Form.useWatch([
|
||||||
|
ZoneFormField.AreaConditions,
|
||||||
|
field.name,
|
||||||
|
'type',
|
||||||
|
]);
|
||||||
|
const intl = useIntl();
|
||||||
|
return (
|
||||||
|
<Flex gap="middle" style={{ marginBottom: 16 }}>
|
||||||
|
<Space align="baseline">
|
||||||
|
<Form.Item
|
||||||
|
name={[field.name, 'type']}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.category.name',
|
||||||
|
defaultMessage: 'Danh mục',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.category.not_empty',
|
||||||
|
defaultMessage: 'Category cannot be empty!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.category.add',
|
||||||
|
defaultMessage: 'Add category',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Select.Option value="month_range">
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.condition.yearly_select"
|
||||||
|
defaultMessage="Yearly"
|
||||||
|
/>
|
||||||
|
</Select.Option>
|
||||||
|
<Select.Option value="date_range">
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.condition.specific_time_select"
|
||||||
|
defaultMessage="Specific date"
|
||||||
|
/>
|
||||||
|
</Select.Option>
|
||||||
|
<Select.Option value="length_limit">
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.condition.length_limit"
|
||||||
|
defaultMessage="Length limit"
|
||||||
|
/>
|
||||||
|
</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</Form.Item>
|
||||||
|
|
||||||
|
{selectedType !== undefined &&
|
||||||
|
(selectedType === 'length_limit' ? (
|
||||||
|
<Form.Item
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.condition.length_limit',
|
||||||
|
defaultMessage: 'Length limit',
|
||||||
|
})}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
<Form.Item
|
||||||
|
name={[field.name, 'min']}
|
||||||
|
noStyle
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'common.not_empty',
|
||||||
|
defaultMessage: 'Cannot be empty!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.condition.length_limit_min',
|
||||||
|
defaultMessage: 'Minimum',
|
||||||
|
})}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name={[field.name, 'max']}
|
||||||
|
noStyle
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'common.not_empty',
|
||||||
|
defaultMessage: 'Cannot be empty!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.condition.length_limit_max',
|
||||||
|
defaultMessage: 'Maximum',
|
||||||
|
})}
|
||||||
|
type="number"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
</Form.Item>
|
||||||
|
) : (
|
||||||
|
<Form.Item
|
||||||
|
name={[field.name, 'from']}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.condition.ban_time',
|
||||||
|
defaultMessage: 'Time',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'common.not_empty',
|
||||||
|
defaultMessage: 'Cannot be empty!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<RangePicker
|
||||||
|
picker={selectedType === 'month_range' ? 'month' : 'date'}
|
||||||
|
format={
|
||||||
|
selectedType === 'month_range' ? 'MM/YYYY' : 'DD/MM/YYYY'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<MinusCircleOutlined onClick={() => remove(field.name)} />
|
||||||
|
</Space>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AddConditionFormProps {
|
||||||
|
initialData?: AreaCondition[];
|
||||||
|
isVisible: boolean;
|
||||||
|
setVisible: (visible: boolean) => void;
|
||||||
|
onFinish?: (values: AreaCondition[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddConditionForm = ({
|
||||||
|
initialData,
|
||||||
|
isVisible,
|
||||||
|
setVisible,
|
||||||
|
onFinish,
|
||||||
|
}: AddConditionFormProps) => {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
form.resetFields();
|
||||||
|
|
||||||
|
if (initialData && initialData.length > 0) {
|
||||||
|
form.setFieldsValue({ conditions: transformToFormValues(initialData) });
|
||||||
|
} else {
|
||||||
|
form.setFieldsValue({ conditions: [{}] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isVisible, initialData]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
const values = await form.validateFields();
|
||||||
|
const transformedConditions = transformToAreaCondition(values.conditions);
|
||||||
|
onFinish?.(transformedConditions);
|
||||||
|
setVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Validation failed', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalForm
|
||||||
|
form={form}
|
||||||
|
open={isVisible}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) form.resetFields();
|
||||||
|
setVisible(open);
|
||||||
|
}}
|
||||||
|
title={
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.category.add"
|
||||||
|
defaultMessage="Add category"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
width="600px"
|
||||||
|
submitter={{
|
||||||
|
searchConfig: { submitText: 'Lưu' },
|
||||||
|
render: (_, doms) => (
|
||||||
|
<Flex justify="center" gap={16}>
|
||||||
|
{doms}
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
>
|
||||||
|
<Form.List name={ZoneFormField.AreaConditions}>
|
||||||
|
{(fields, { add, remove }) => (
|
||||||
|
<>
|
||||||
|
{fields.map((field) => (
|
||||||
|
<ConditionRow
|
||||||
|
key={field.key}
|
||||||
|
field={field}
|
||||||
|
remove={remove}
|
||||||
|
// isFirst={index === 0}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<Form.Item>
|
||||||
|
<Flex justify="center">
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
onClick={() => add()}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
style={{ width: 300 }}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.category.add"
|
||||||
|
defaultMessage="Add category"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Form.List>
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddConditionForm;
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import { getCircleRadius } from '@/utils/slave/sgw/geomUtils';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
ProFormDigit,
|
||||||
|
ProFormInstance,
|
||||||
|
ProFormItem,
|
||||||
|
ProFormText,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||||
|
import { Button, Col, Flex, Form, Input, Row, Tag, Tooltip } from 'antd';
|
||||||
|
import { MutableRefObject, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
CircleGeometry,
|
||||||
|
GeometryType,
|
||||||
|
PolygonGeometry,
|
||||||
|
tagPlusStyle,
|
||||||
|
validateGeometry,
|
||||||
|
ZoneFormData,
|
||||||
|
ZoneFormField,
|
||||||
|
} from '../type';
|
||||||
|
import PolygonModal from './PolygonModal';
|
||||||
|
|
||||||
|
type GeometryFormProps = {
|
||||||
|
shape?: number;
|
||||||
|
form: MutableRefObject<ProFormInstance<ZoneFormData> | null | undefined>;
|
||||||
|
zoneData?: SgwModel.Geom;
|
||||||
|
};
|
||||||
|
|
||||||
|
const GeometryForm = ({ shape, form }: GeometryFormProps) => {
|
||||||
|
const [isPolygonModalOpen, setIsPolygonModalOpen] = useState<boolean>(false);
|
||||||
|
const [indexTag, setIndexTag] = useState<number>(-1);
|
||||||
|
const intl = useIntl();
|
||||||
|
const polygonGeometry =
|
||||||
|
(Form.useWatch(
|
||||||
|
ZoneFormField.PolygonGeometry,
|
||||||
|
form.current || undefined,
|
||||||
|
) as PolygonGeometry[]) || [];
|
||||||
|
|
||||||
|
// Circle area calculation (subscribe to Radius so it updates)
|
||||||
|
const area = Form.useWatch(
|
||||||
|
[ZoneFormField.CircleData, 'area'],
|
||||||
|
form.current || undefined,
|
||||||
|
) as number | undefined;
|
||||||
|
|
||||||
|
const radiusArea = useMemo(() => {
|
||||||
|
if (shape === GeometryType.CIRCLE) {
|
||||||
|
if (area && area > 0) {
|
||||||
|
return getCircleRadius(area);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}, [shape, area]);
|
||||||
|
|
||||||
|
const handleRemovePolygon = (index: number) => {
|
||||||
|
const newPolygons = polygonGeometry.filter(
|
||||||
|
(_, i) => i !== index,
|
||||||
|
) as PolygonGeometry[];
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.PolygonGeometry]: newPolygons,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditPolygon = (index: number) => {
|
||||||
|
setIsPolygonModalOpen(true);
|
||||||
|
setIndexTag(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format coordinates for display
|
||||||
|
const formatCoords = (coords: number[][]) => {
|
||||||
|
return coords.map((c) => `[${c[0]}, ${c[1]}]`).join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (shape) {
|
||||||
|
case GeometryType.POLYGON:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ProFormItem
|
||||||
|
name={ZoneFormField.PolygonGeometry}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates',
|
||||||
|
defaultMessage: 'Tọa độ',
|
||||||
|
})}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<Flex gap="10px 4px" wrap>
|
||||||
|
{polygonGeometry.map((polygon, index) => (
|
||||||
|
<Tooltip
|
||||||
|
key={index}
|
||||||
|
title={formatCoords(polygon.geometry) || ''}
|
||||||
|
>
|
||||||
|
<Tag
|
||||||
|
closable
|
||||||
|
onClose={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleRemovePolygon(index);
|
||||||
|
}}
|
||||||
|
onClick={() => handleEditPolygon(index)}
|
||||||
|
color="blue"
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'banzones.title',
|
||||||
|
defaultMessage: 'Khu vực',
|
||||||
|
})}{' '}
|
||||||
|
{index + 1}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
style={tagPlusStyle}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
setIndexTag(-1);
|
||||||
|
setIsPolygonModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.geometry.add_zone"
|
||||||
|
defaultMessage="Thêm vùng"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</ProFormItem>
|
||||||
|
<PolygonModal
|
||||||
|
isVisible={isPolygonModalOpen}
|
||||||
|
setVisible={setIsPolygonModalOpen}
|
||||||
|
initialData={polygonGeometry}
|
||||||
|
index={indexTag}
|
||||||
|
handleSubmit={async (values) => {
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.PolygonGeometry]: values,
|
||||||
|
});
|
||||||
|
setIndexTag(-1);
|
||||||
|
setIsPolygonModalOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
case GeometryType.LINESTRING:
|
||||||
|
return (
|
||||||
|
<ProFormItem
|
||||||
|
required
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates.not_empty',
|
||||||
|
defaultMessage: 'Tọa độ không được để trống!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validator: (_: any, value: string) => {
|
||||||
|
return validateGeometry(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
name={ZoneFormField.PolylineData}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates',
|
||||||
|
defaultMessage: 'Tọa độ',
|
||||||
|
})}
|
||||||
|
tooltip={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates.tooltip',
|
||||||
|
defaultMessage:
|
||||||
|
'Danh sách các toạ độ theo định dạng: [[longitude,latitude],[longitude,latitude],...]',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
autoSize={{ minRows: 5, maxRows: 10 }}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates.placeholder',
|
||||||
|
defaultMessage:
|
||||||
|
'Ví dụ: [[109.5, 12.3], [109.6, 12.4], [109.7, 12.5]]',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ProFormItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
case GeometryType.CIRCLE:
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormText
|
||||||
|
name={[ZoneFormField.CircleData, 'center', 0]}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.longitude',
|
||||||
|
defaultMessage: 'Kinh độ',
|
||||||
|
})}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.longitude.placeholder',
|
||||||
|
defaultMessage: 'Nhập kinh độ',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.longitude.required',
|
||||||
|
defaultMessage: 'Kinh độ không được để trống!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fieldProps={{
|
||||||
|
type: 'number',
|
||||||
|
step: '0.000001',
|
||||||
|
onChange: (e) => {
|
||||||
|
const lng = parseFloat(e.target.value);
|
||||||
|
const currentCircle = (form.current?.getFieldValue(
|
||||||
|
ZoneFormField.CircleData,
|
||||||
|
) as CircleGeometry) || { center: [0, 0], area: 0 };
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.CircleData]: {
|
||||||
|
...currentCircle,
|
||||||
|
center: [lng, currentCircle.center?.[1] || 0],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormText
|
||||||
|
name={[ZoneFormField.CircleData, 'center', 1]}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.latitude',
|
||||||
|
defaultMessage: 'Vĩ độ',
|
||||||
|
})}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.latitude.placeholder',
|
||||||
|
defaultMessage: 'Nhập vĩ độ',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.latitude.required',
|
||||||
|
defaultMessage: 'Vĩ độ không được để trống!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
fieldProps={{
|
||||||
|
type: 'number',
|
||||||
|
step: '0.000001',
|
||||||
|
onChange: (e) => {
|
||||||
|
const lat = parseFloat(e.target.value);
|
||||||
|
const currentCircle = (form.current?.getFieldValue(
|
||||||
|
ZoneFormField.CircleData,
|
||||||
|
) as CircleGeometry) || { center: [0, 0], radius: 0 };
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.CircleData]: {
|
||||||
|
...currentCircle,
|
||||||
|
center: [currentCircle.center?.[0] || 0, lat],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormDigit
|
||||||
|
name={[ZoneFormField.CircleData, 'area']}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.area',
|
||||||
|
defaultMessage: 'Diện tích (Hecta)',
|
||||||
|
})}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.area.placeholder',
|
||||||
|
defaultMessage: 'Nhập diện tích',
|
||||||
|
})}
|
||||||
|
min={1}
|
||||||
|
fieldProps={{
|
||||||
|
precision: 0,
|
||||||
|
onChange: (value) => {
|
||||||
|
const currentCircle = (form.current?.getFieldValue(
|
||||||
|
ZoneFormField.CircleData,
|
||||||
|
) as CircleGeometry) || { center: [0, 0], radius: 0 };
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.CircleData]: {
|
||||||
|
center: currentCircle.center || [0, 0],
|
||||||
|
area: value || 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.area.required',
|
||||||
|
defaultMessage: 'Diện tích không được để trống!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<ProFormItem
|
||||||
|
name={[ZoneFormField.CircleData, 'radius']}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.radius',
|
||||||
|
defaultMessage: 'Bán kính (m)',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
disabled
|
||||||
|
readOnly
|
||||||
|
value={
|
||||||
|
radiusArea > 0
|
||||||
|
? `${radiusArea} ${intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.metrics',
|
||||||
|
defaultMessage: 'mét',
|
||||||
|
})}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
addonAfter={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.auto_calculate',
|
||||||
|
defaultMessage: 'Tự động tính',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ProFormItem>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <div>Vui lòng chọn loại hình học để nhập toạ độ.</div>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GeometryForm;
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import {
|
||||||
|
ModalForm,
|
||||||
|
ProFormItem,
|
||||||
|
ProFormList,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { Flex, Input, theme } from 'antd';
|
||||||
|
import { useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { PolygonGeometry, validateGeometry } from '../type';
|
||||||
|
|
||||||
|
interface PolygonModalProps {
|
||||||
|
initialData?: PolygonGeometry[];
|
||||||
|
index?: number;
|
||||||
|
isVisible: boolean;
|
||||||
|
setVisible: (visible: boolean) => void;
|
||||||
|
handleSubmit: (values: PolygonGeometry[]) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PolygonModal = ({
|
||||||
|
isVisible,
|
||||||
|
setVisible,
|
||||||
|
handleSubmit,
|
||||||
|
initialData,
|
||||||
|
index,
|
||||||
|
}: PolygonModalProps) => {
|
||||||
|
const formRef = useRef<any>();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const intl = useIntl();
|
||||||
|
// Counter to track item index during render
|
||||||
|
let itemIndex = 0;
|
||||||
|
// Convert initialData to form format
|
||||||
|
const initialValues = useMemo(() => {
|
||||||
|
if (!initialData || initialData.length === 0) {
|
||||||
|
return { conditions: [] };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
conditions: initialData.map((item) => ({
|
||||||
|
geometry: JSON.stringify(item.geometry),
|
||||||
|
id: item.id || undefined,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}, [initialData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible) {
|
||||||
|
formRef.current?.setFieldsValue(initialValues);
|
||||||
|
}
|
||||||
|
}, [isVisible, initialValues]);
|
||||||
|
|
||||||
|
// Handle form submit - convert back to AreaGeometry format
|
||||||
|
const handleFinish = async (values: any) => {
|
||||||
|
const conditions = values.conditions || [];
|
||||||
|
const result: PolygonGeometry[] = conditions.map((item: any) => ({
|
||||||
|
geometry: JSON.parse(item.geometry),
|
||||||
|
id: item.id,
|
||||||
|
}));
|
||||||
|
await handleSubmit(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Global style for highlighting TextArea border */}
|
||||||
|
<style>{`
|
||||||
|
.highlighted-item .ant-input {
|
||||||
|
border-color: ${token.colorWarningActive} !important;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
<ModalForm
|
||||||
|
formRef={formRef}
|
||||||
|
open={isVisible}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) formRef.current?.resetFields();
|
||||||
|
setVisible(open);
|
||||||
|
}}
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'banzone.polygon_modal.title',
|
||||||
|
defaultMessage: 'Thêm toạ độ',
|
||||||
|
})}
|
||||||
|
width="40%"
|
||||||
|
initialValues={initialValues}
|
||||||
|
submitter={{
|
||||||
|
searchConfig: {
|
||||||
|
submitText: intl.formatMessage({
|
||||||
|
id: 'common.save',
|
||||||
|
defaultMessage: 'Lưu',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
render: (_, doms) => (
|
||||||
|
<Flex justify="center" gap={16}>
|
||||||
|
{doms}
|
||||||
|
</Flex>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
onFinish={handleFinish}
|
||||||
|
>
|
||||||
|
<ProFormList
|
||||||
|
required
|
||||||
|
name="conditions"
|
||||||
|
creatorButtonProps={{
|
||||||
|
position: 'bottom',
|
||||||
|
creatorButtonText: intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.add_zone',
|
||||||
|
defaultMessage: 'Thêm toạ độ',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
copyIconProps={false}
|
||||||
|
deleteIconProps={{
|
||||||
|
tooltipText: intl.formatMessage({
|
||||||
|
id: 'common.delete',
|
||||||
|
defaultMessage: 'Xoá',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
itemRender={({ listDom, action }) => {
|
||||||
|
const currentIndex = itemIndex++;
|
||||||
|
const isHighlighted = index !== undefined && currentIndex === index;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={isHighlighted ? 'highlighted-item' : ''}
|
||||||
|
data-item-index={currentIndex}
|
||||||
|
>
|
||||||
|
{listDom}
|
||||||
|
<Flex gap={8} justify="flex-end">
|
||||||
|
{action}
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Hidden field for id - needed to preserve id when editing */}
|
||||||
|
<ProFormItem name="id" hidden>
|
||||||
|
<input type="hidden" />
|
||||||
|
</ProFormItem>
|
||||||
|
<ProFormItem
|
||||||
|
name="geometry"
|
||||||
|
required
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates.not_empty',
|
||||||
|
defaultMessage: 'Coordinates cannot be empty!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
validator: (_rule: any, value: any) => {
|
||||||
|
return validateGeometry(value);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates',
|
||||||
|
defaultMessage: 'Coordinates',
|
||||||
|
})}
|
||||||
|
tooltip={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates.tooltip',
|
||||||
|
defaultMessage:
|
||||||
|
'Danh sách các toạ độ theo định dạng: [[longitude,latitude],[longitude,latitude],...]',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Input.TextArea
|
||||||
|
autoSize={{ minRows: 5, maxRows: 10 }}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.geometry.coordinates.placeholder',
|
||||||
|
defaultMessage:
|
||||||
|
'Example: [[109.5, 12.3], [109.6, 12.4], [109.7, 12.5]]',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ProFormItem>
|
||||||
|
</ProFormList>
|
||||||
|
</ModalForm>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PolygonModal;
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
ProForm,
|
||||||
|
ProFormInstance,
|
||||||
|
ProFormSelect,
|
||||||
|
ProFormSwitch,
|
||||||
|
ProFormText,
|
||||||
|
ProFormTextArea,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||||
|
import { Button, Col, Flex, Form, Row, Tag, Tooltip } from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { MutableRefObject, useState } from 'react';
|
||||||
|
import { tagPlusStyle, ZoneFormData, ZoneFormField } from '../type';
|
||||||
|
import AddConditionForm from './AddConditionForm';
|
||||||
|
import GeometryForm from './GeometryForm';
|
||||||
|
interface ZoneFormProps {
|
||||||
|
shape?: number;
|
||||||
|
formRef: MutableRefObject<ProFormInstance<ZoneFormData> | null | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ZoneForm = ({ formRef, shape }: ZoneFormProps) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [isConditionModalOpen, setIsConditionModalOpen] = useState(false);
|
||||||
|
const handleGroupSelect = (groupId: string | string[] | null) => {
|
||||||
|
formRef.current?.setFieldsValue({ [ZoneFormField.AreaId]: groupId });
|
||||||
|
};
|
||||||
|
const selectedGroupIds = Form.useWatch(
|
||||||
|
ZoneFormField.AreaId,
|
||||||
|
formRef.current || undefined,
|
||||||
|
) as string | string[] | null | undefined;
|
||||||
|
const conditionData = Form.useWatch(
|
||||||
|
ZoneFormField.AreaConditions,
|
||||||
|
formRef.current || undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleConditionsClose = (indexToRemove: number) => {
|
||||||
|
formRef.current?.setFieldValue(
|
||||||
|
ZoneFormField.AreaConditions,
|
||||||
|
conditionData?.filter((_, index) => index !== indexToRemove),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Row gutter={16}>
|
||||||
|
{/* Tên */}
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<ProFormText
|
||||||
|
name={ZoneFormField.AreaName}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'common.name',
|
||||||
|
defaultMessage: 'Tên',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.form.name.required.message',
|
||||||
|
defaultMessage: 'Tên khu vực không được để trống!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* Loại */}
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<ProFormSelect
|
||||||
|
name={ZoneFormField.AreaType}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'common.type',
|
||||||
|
defaultMessage: 'Loại',
|
||||||
|
})}
|
||||||
|
required
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.form.area_type.required.message',
|
||||||
|
defaultMessage: 'Loại khu vực không được để trống',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.form.area_type.placeholder',
|
||||||
|
defaultMessage: 'Chọn loại khu vực',
|
||||||
|
})}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'banzone.area.fishing_ban',
|
||||||
|
defaultMessage: 'Cấm đánh bắt',
|
||||||
|
}),
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'banzone.area.move_ban',
|
||||||
|
defaultMessage: 'Cấm di chuyển',
|
||||||
|
}),
|
||||||
|
value: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'banzone.area.safe',
|
||||||
|
defaultMessage: 'Vùng an toàn',
|
||||||
|
}),
|
||||||
|
value: 3,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<Row gutter={16}>
|
||||||
|
{/* Tỉnh */}
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<ProForm.Item
|
||||||
|
name={ZoneFormField.AreaId}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'common.province',
|
||||||
|
defaultMessage: 'Tỉnh',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'banzone.form.province.required.message',
|
||||||
|
defaultMessage: 'Tỉnh quản lý không được để trống!',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<TreeSelectedGroup
|
||||||
|
groupIds={selectedGroupIds ?? ''}
|
||||||
|
onSelected={handleGroupSelect}
|
||||||
|
/>
|
||||||
|
</ProForm.Item>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
{/* Có hiệu lực */}
|
||||||
|
<Col xs={24} md={12}>
|
||||||
|
<ProFormSwitch
|
||||||
|
name={ZoneFormField.AreaEnabled}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.is_enable',
|
||||||
|
defaultMessage: 'Có hiệu lực',
|
||||||
|
})}
|
||||||
|
valuePropName="checked"
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
<ProFormTextArea
|
||||||
|
name={ZoneFormField.AreaDescription}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'common.description',
|
||||||
|
defaultMessage: 'Mô tả',
|
||||||
|
})}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'banzone.form.description.placeholder',
|
||||||
|
defaultMessage: 'Nhập mô tả khu vực',
|
||||||
|
})}
|
||||||
|
autoSize={{ minRows: 3, maxRows: 5 }}
|
||||||
|
/>
|
||||||
|
<Form.Item
|
||||||
|
name={ZoneFormField.AreaConditions}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'banzone.condition',
|
||||||
|
defaultMessage: 'Điều kiện',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Flex gap="10px 4px" wrap>
|
||||||
|
{(conditionData || []).map((condition, index) => {
|
||||||
|
// console.log("Condition: ", condition);
|
||||||
|
|
||||||
|
let tootip = '';
|
||||||
|
let label = '';
|
||||||
|
|
||||||
|
const { type } = condition;
|
||||||
|
switch (type) {
|
||||||
|
case 'month_range': {
|
||||||
|
label = intl.formatMessage({
|
||||||
|
id: 'banzone.condition.yearly',
|
||||||
|
defaultMessage: 'Hàng năm',
|
||||||
|
});
|
||||||
|
const fromMonth = condition.from + 1;
|
||||||
|
const toMonth = condition.to + 1;
|
||||||
|
tootip = `Tháng từ ${fromMonth} đến tháng ${toMonth}`;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'date_range': {
|
||||||
|
label = intl.formatMessage({
|
||||||
|
id: 'banzone.condition.specific_time',
|
||||||
|
defaultMessage: 'Thời gian cụ thể',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromDate = dayjs(condition.from).format('DD/MM/YYYY');
|
||||||
|
const toDate = dayjs(condition.to).format('DD/MM/YYYY');
|
||||||
|
tootip = `Từ ${fromDate} đến ${toDate}`;
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'length_limit':
|
||||||
|
label = intl.formatMessage({
|
||||||
|
id: 'banzone.condition.length_limit',
|
||||||
|
defaultMessage: 'Chiều dài cho phép',
|
||||||
|
});
|
||||||
|
tootip = `Chiều dài tàu từ ${condition.min} đến ${condition.max} mét`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
label = intl.formatMessage({
|
||||||
|
id: 'common.undefined',
|
||||||
|
defaultMessage: 'Không xác định',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={tootip} key={index}>
|
||||||
|
<Tag
|
||||||
|
closable
|
||||||
|
onClick={() => setIsConditionModalOpen(true)}
|
||||||
|
onClose={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleConditionsClose(index);
|
||||||
|
}}
|
||||||
|
color={
|
||||||
|
type === 'month_range'
|
||||||
|
? 'blue'
|
||||||
|
: type === 'date_range'
|
||||||
|
? 'green'
|
||||||
|
: 'volcano'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
style={tagPlusStyle}
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={() => setIsConditionModalOpen(true)}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.condition.add"
|
||||||
|
defaultMessage="Thêm điều kiện"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
</Form.Item>
|
||||||
|
<GeometryForm shape={shape} form={formRef} />
|
||||||
|
<AddConditionForm
|
||||||
|
isVisible={isConditionModalOpen}
|
||||||
|
setVisible={setIsConditionModalOpen}
|
||||||
|
initialData={conditionData}
|
||||||
|
onFinish={(newConditions) => {
|
||||||
|
try {
|
||||||
|
formRef.current?.setFieldValue(
|
||||||
|
ZoneFormField.AreaConditions,
|
||||||
|
newConditions,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error setting form value:', e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ZoneForm;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { useMapGeometrySync } from './useMapGeometrySync';
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
import { BaseMap, ZoneData } from '@/pages/Slave/SGW/Map/type';
|
||||||
|
import {
|
||||||
|
getAreaFromRadius,
|
||||||
|
getCircleRadius,
|
||||||
|
} from '@/utils/slave/sgw/geomUtils';
|
||||||
|
import { type ProFormInstance } from '@ant-design/pro-components';
|
||||||
|
import { Feature } from 'ol';
|
||||||
|
import { MutableRefObject, useEffect, useRef } from 'react';
|
||||||
|
import type { ZoneFormData } from '../type';
|
||||||
|
import { GeometryType, ZoneFormField } from '../type';
|
||||||
|
|
||||||
|
// Default zone data for drawing features
|
||||||
|
const DEFAULT_ZONE_DATA: ZoneData = { type: 'default' };
|
||||||
|
|
||||||
|
interface DrawnFeatureData {
|
||||||
|
type: string;
|
||||||
|
coordinates: any;
|
||||||
|
feature: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapGeometrySyncOptions {
|
||||||
|
baseMap: MutableRefObject<BaseMap | null>;
|
||||||
|
dataLayerId: string;
|
||||||
|
form: MutableRefObject<ProFormInstance<ZoneFormData> | undefined>;
|
||||||
|
shape?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to sync geometry between Map and Form
|
||||||
|
* - Draw on Map -> Update Form
|
||||||
|
* - Modify on Map -> Update Form
|
||||||
|
* - Form changes -> Update Map (optional)
|
||||||
|
*/
|
||||||
|
export const useMapGeometrySync = (options: MapGeometrySyncOptions) => {
|
||||||
|
const { baseMap, dataLayerId, form, enabled = true } = options;
|
||||||
|
const isHandlingUpdateRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!baseMap || !enabled) return;
|
||||||
|
|
||||||
|
// Handle feature drawn event
|
||||||
|
const handleFeatureDrawn = (data: DrawnFeatureData) => {
|
||||||
|
if (isHandlingUpdateRef.current) return;
|
||||||
|
isHandlingUpdateRef.current = true;
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'Polygon': {
|
||||||
|
const currentGeometry =
|
||||||
|
form.current?.getFieldValue(ZoneFormField.PolygonGeometry) || [];
|
||||||
|
const polygonId = `polygon_${Date.now()}`; // Tạo unique ID
|
||||||
|
data.feature.set('polygonId', polygonId); // Sửa: set trực tiếp key-value
|
||||||
|
const polygonCoords = data.coordinates;
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.PolygonGeometry]: [
|
||||||
|
...currentGeometry,
|
||||||
|
{
|
||||||
|
geometry: polygonCoords[0],
|
||||||
|
id: polygonId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'LineString': {
|
||||||
|
baseMap.current?.clearFeatures(dataLayerId);
|
||||||
|
const lineCoords = data.coordinates;
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.PolylineData]: JSON.stringify(lineCoords),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Circle': {
|
||||||
|
baseMap.current?.clearFeatures(dataLayerId);
|
||||||
|
const circleData = data.coordinates;
|
||||||
|
console.log('Circle Data: ', circleData);
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.CircleData]: {
|
||||||
|
center: circleData.center,
|
||||||
|
area: getAreaFromRadius(circleData.radius),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Point':
|
||||||
|
// Point is stored as circle with small radius
|
||||||
|
// form?.setFieldsValue({
|
||||||
|
// [ZoneFormField.CircleData]: {
|
||||||
|
// center: data.coordinates,
|
||||||
|
// radius: 100, // Default radius for point
|
||||||
|
// },
|
||||||
|
// [ZoneFormField.Radius]: 100,
|
||||||
|
// });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isHandlingUpdateRef.current = false;
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle feature modified event
|
||||||
|
const handleFeatureModified = (data: {
|
||||||
|
feature: any;
|
||||||
|
coordinates: any;
|
||||||
|
}) => {
|
||||||
|
if (isHandlingUpdateRef.current) return;
|
||||||
|
isHandlingUpdateRef.current = true;
|
||||||
|
|
||||||
|
const geometry = data.feature.getGeometry();
|
||||||
|
if (!geometry) {
|
||||||
|
isHandlingUpdateRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const geomType = geometry.getType();
|
||||||
|
|
||||||
|
switch (geomType) {
|
||||||
|
case 'Polygon': {
|
||||||
|
const polygonId = data.feature.get('polygonId');
|
||||||
|
|
||||||
|
if (polygonId) {
|
||||||
|
const currentGeometry =
|
||||||
|
form.current?.getFieldValue(ZoneFormField.PolygonGeometry) || [];
|
||||||
|
const modifiedCoords = data.coordinates[0];
|
||||||
|
const updatedGeometry = currentGeometry.map((item: any) =>
|
||||||
|
item.id === polygonId
|
||||||
|
? { ...item, geometry: modifiedCoords }
|
||||||
|
: item,
|
||||||
|
);
|
||||||
|
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.PolygonGeometry]: updatedGeometry,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'LineString': {
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.PolylineData]: JSON.stringify(data.coordinates),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'Circle': {
|
||||||
|
form.current?.setFieldsValue({
|
||||||
|
[ZoneFormField.CircleData]: {
|
||||||
|
center: data.coordinates.center,
|
||||||
|
// radius: data.coordinates.radius,
|
||||||
|
area: getAreaFromRadius(data.coordinates.radius),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isHandlingUpdateRef.current = false;
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register event listeners
|
||||||
|
baseMap.current?.onFeatureDrawn(handleFeatureDrawn);
|
||||||
|
baseMap.current?.onFeatureModified(handleFeatureModified);
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
// Note: BaseMap doesn't have off method, so events will remain
|
||||||
|
// This is acceptable as the component unmounts
|
||||||
|
};
|
||||||
|
}, [baseMap, enabled]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update map display based on current form data
|
||||||
|
*/
|
||||||
|
const updateMapFromForm = (type: GeometryType) => {
|
||||||
|
if (!baseMap || !form) return;
|
||||||
|
const formCurrent = form.current;
|
||||||
|
let geometryData;
|
||||||
|
if (type === GeometryType.POLYGON) {
|
||||||
|
geometryData =
|
||||||
|
formCurrent?.getFieldValue(ZoneFormField.PolygonGeometry) || [];
|
||||||
|
} else if (type === GeometryType.LINESTRING) {
|
||||||
|
const polylineString =
|
||||||
|
formCurrent?.getFieldValue(ZoneFormField.PolylineData) || [];
|
||||||
|
geometryData = JSON.parse(polylineString);
|
||||||
|
} else if (type === GeometryType.CIRCLE) {
|
||||||
|
geometryData = formCurrent?.getFieldValue(ZoneFormField.CircleData)
|
||||||
|
? [formCurrent?.getFieldValue(ZoneFormField.CircleData)]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!geometryData || geometryData.length === 0) {
|
||||||
|
baseMap.current?.clearFeatures(dataLayerId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu đang xử lý event từ Map (vẽ/sửa), KHÔNG clear và vẽ lại
|
||||||
|
// vì Draw interaction đã add feature vào map sẵn rồi
|
||||||
|
if (isHandlingUpdateRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear existing features trước khi vẽ lại (chỉ khi không đang handle map event)
|
||||||
|
baseMap.current?.clearFeatures(dataLayerId);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case GeometryType.POLYGON:
|
||||||
|
{
|
||||||
|
const features: Feature[] = [];
|
||||||
|
geometryData.forEach((geom: any) => {
|
||||||
|
const feature = baseMap.current?.addPolygon(
|
||||||
|
dataLayerId,
|
||||||
|
[geom.geometry],
|
||||||
|
DEFAULT_ZONE_DATA,
|
||||||
|
geom.id,
|
||||||
|
);
|
||||||
|
features.push(feature!);
|
||||||
|
});
|
||||||
|
baseMap.current?.zoomToFeatures(features);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case GeometryType.LINESTRING:
|
||||||
|
{
|
||||||
|
const feature = baseMap.current?.addPolyline(
|
||||||
|
dataLayerId,
|
||||||
|
geometryData,
|
||||||
|
DEFAULT_ZONE_DATA,
|
||||||
|
);
|
||||||
|
baseMap.current?.zoomToFeatures([feature!]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case GeometryType.CIRCLE:
|
||||||
|
{
|
||||||
|
const feature = baseMap.current?.addCircle(
|
||||||
|
dataLayerId,
|
||||||
|
geometryData[0].center,
|
||||||
|
getCircleRadius(geometryData[0].area),
|
||||||
|
DEFAULT_ZONE_DATA,
|
||||||
|
);
|
||||||
|
baseMap.current?.zoomToFeatures([feature!]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all drawn features from map
|
||||||
|
*/
|
||||||
|
const clearMapFeatures = () => {
|
||||||
|
if (!baseMap) return;
|
||||||
|
baseMap.current?.clearFeatures(dataLayerId);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draw existing geometry on map (for update mode)
|
||||||
|
*/
|
||||||
|
const drawExistingGeometry = (geometry: any) => {
|
||||||
|
if (!baseMap || !geometry) return;
|
||||||
|
|
||||||
|
clearMapFeatures();
|
||||||
|
|
||||||
|
if (geometry.geom_type === GeometryType.POLYGON && geometry.polygons) {
|
||||||
|
geometry.polygons.forEach((polygonCoords: number[][]) => {
|
||||||
|
baseMap.current?.addPolygon(
|
||||||
|
dataLayerId,
|
||||||
|
[polygonCoords],
|
||||||
|
DEFAULT_ZONE_DATA,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
geometry.geom_type === GeometryType.LINESTRING &&
|
||||||
|
geometry.coordinates
|
||||||
|
) {
|
||||||
|
baseMap.current?.addPolyline(
|
||||||
|
dataLayerId,
|
||||||
|
geometry.coordinates,
|
||||||
|
DEFAULT_ZONE_DATA,
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
geometry.geom_type === GeometryType.CIRCLE &&
|
||||||
|
geometry.center &&
|
||||||
|
geometry.radius
|
||||||
|
) {
|
||||||
|
baseMap.current?.addCircle(
|
||||||
|
dataLayerId,
|
||||||
|
geometry.center,
|
||||||
|
geometry.radius,
|
||||||
|
DEFAULT_ZONE_DATA,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
updateMapFromForm,
|
||||||
|
clearMapFeatures,
|
||||||
|
drawExistingGeometry,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default useMapGeometrySync;
|
||||||
520
src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/index.tsx
Normal file
520
src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/index.tsx
Normal file
@@ -0,0 +1,520 @@
|
|||||||
|
import { SGW_ROUTE_BANZONES_LIST } from '@/constants/slave/sgw/routes';
|
||||||
|
import VietNamMap, {
|
||||||
|
VietNamMapRef,
|
||||||
|
} from '@/pages/Slave/SGW/Map/components/VietNamMap';
|
||||||
|
import { BaseMap, DATA_LAYER } from '@/pages/Slave/SGW/Map/type';
|
||||||
|
import {
|
||||||
|
apiCreateBanzone,
|
||||||
|
apiGetZoneById,
|
||||||
|
apiUpdateBanzone,
|
||||||
|
} from '@/services/slave/sgw/ZoneController';
|
||||||
|
import {
|
||||||
|
getAreaFromRadius,
|
||||||
|
getCircleRadius,
|
||||||
|
} from '@/utils/slave/sgw/geomUtils';
|
||||||
|
import { DeleteOutlined, EditFilled, GatewayOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
PageContainer,
|
||||||
|
ProCard,
|
||||||
|
ProForm,
|
||||||
|
ProFormInstance,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import {
|
||||||
|
FormattedMessage,
|
||||||
|
history,
|
||||||
|
useIntl,
|
||||||
|
useLocation,
|
||||||
|
useModel,
|
||||||
|
useParams,
|
||||||
|
} from '@umijs/max';
|
||||||
|
import { Button, Flex, Form, Grid, message } from 'antd';
|
||||||
|
import VectorLayer from 'ol/layer/Vector';
|
||||||
|
import VectorSource from 'ol/source/Vector';
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import ZoneForm from './components/ZoneForm';
|
||||||
|
import { useMapGeometrySync } from './hooks';
|
||||||
|
import {
|
||||||
|
AreaCondition,
|
||||||
|
checkValidateGeometry,
|
||||||
|
DrawActionType,
|
||||||
|
formatLineStringWKT,
|
||||||
|
formatPolygonGeometryToWKT,
|
||||||
|
GeometryType,
|
||||||
|
parseLineStringWKT,
|
||||||
|
parseMultiPolygonWKT,
|
||||||
|
parsePointWKT,
|
||||||
|
PolygonGeometry,
|
||||||
|
ZoneFormData,
|
||||||
|
ZoneFormField,
|
||||||
|
ZoneLocationState,
|
||||||
|
} from './type';
|
||||||
|
const CreateOrUpdateBanzone = () => {
|
||||||
|
const location = useLocation() as { state: ZoneLocationState };
|
||||||
|
const shape = location.state?.shape;
|
||||||
|
const type = location.state?.type;
|
||||||
|
const { id: zoneId } = useParams();
|
||||||
|
const intl = useIntl();
|
||||||
|
const { useBreakpoint } = Grid;
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const formRef = useRef<ProFormInstance<ZoneFormData>>();
|
||||||
|
const vietNamMapRef = useRef<VietNamMapRef>(null);
|
||||||
|
const baseMap = useRef<BaseMap | null>(null);
|
||||||
|
const drawController = useRef<any>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [formReady, setFormReady] = useState(false);
|
||||||
|
const { groupMap } = useModel('master.useGroups');
|
||||||
|
const [mapActions, setMapActions] = useState<DrawActionType>({
|
||||||
|
isDrawing: false,
|
||||||
|
isModifying: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { clearMapFeatures, updateMapFromForm } = useMapGeometrySync({
|
||||||
|
baseMap: baseMap,
|
||||||
|
dataLayerId: DATA_LAYER,
|
||||||
|
form: formRef,
|
||||||
|
shape,
|
||||||
|
enabled: !!baseMap.current,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handler for back navigation
|
||||||
|
const handleBack = () => {
|
||||||
|
history.push(SGW_ROUTE_BANZONES_LIST);
|
||||||
|
formRef.current?.resetFields();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMapReady = useCallback((baseMapInstance: BaseMap) => {
|
||||||
|
baseMap.current = baseMapInstance;
|
||||||
|
// Create a vector layer for dynamic features
|
||||||
|
const vectorDataLayer = new VectorLayer({
|
||||||
|
source: new VectorSource(),
|
||||||
|
});
|
||||||
|
vectorDataLayer.set('id', DATA_LAYER);
|
||||||
|
baseMapInstance.addLayer(vectorDataLayer);
|
||||||
|
baseMapInstance.setView([116.152685, 15.70581], 5);
|
||||||
|
// Initialize draw controller
|
||||||
|
drawController.current = baseMapInstance.DrawAndModifyFeature(DATA_LAYER);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEnableModify = () => {
|
||||||
|
if (drawController.current) {
|
||||||
|
setMapActions({
|
||||||
|
isDrawing: false,
|
||||||
|
isModifying: true,
|
||||||
|
});
|
||||||
|
drawController.current.removeInteractions();
|
||||||
|
drawController.current.enableModify();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (values?: ZoneFormData) => {
|
||||||
|
if (!values) return;
|
||||||
|
// Validate required fields
|
||||||
|
if (!values[ZoneFormField.AreaName]) {
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({ id: 'banzone.form.name.error.required' }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!values[ZoneFormField.AreaType]) {
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({ id: 'banzone.form.name.area_type.required' }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!values[ZoneFormField.AreaId]) {
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({ id: 'banzone.form.name.province.required' }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lineStringData = values[ZoneFormField.PolylineData];
|
||||||
|
const polygonData = values[ZoneFormField.PolygonGeometry];
|
||||||
|
const circleData = values[ZoneFormField.CircleData];
|
||||||
|
|
||||||
|
if (!lineStringData && !polygonData && !circleData) {
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({ id: 'banzone.form.name.geometry.required' }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let geometryJson = '';
|
||||||
|
|
||||||
|
// // Convert geometry to API format
|
||||||
|
switch (shape) {
|
||||||
|
case GeometryType.POLYGON: {
|
||||||
|
const polygonWKT: string = formatPolygonGeometryToWKT(
|
||||||
|
polygonData || [],
|
||||||
|
);
|
||||||
|
geometryJson = JSON.stringify({
|
||||||
|
geom_type: GeometryType.POLYGON,
|
||||||
|
geom_poly: polygonWKT,
|
||||||
|
geom_lines: '',
|
||||||
|
geom_point: '',
|
||||||
|
geom_radius: 0,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GeometryType.LINESTRING: {
|
||||||
|
const polylineData = JSON.parse(lineStringData || '[]');
|
||||||
|
const polylineWKT: string = formatLineStringWKT(polylineData);
|
||||||
|
geometryJson = JSON.stringify({
|
||||||
|
geom_type: GeometryType.LINESTRING,
|
||||||
|
geom_poly: '',
|
||||||
|
geom_lines: polylineWKT,
|
||||||
|
geom_point: '',
|
||||||
|
geom_radius: 0,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GeometryType.CIRCLE: {
|
||||||
|
// For circle, API expects radius in hecta, convert from meters
|
||||||
|
const radiusInMetter = getCircleRadius(circleData?.area || 0);
|
||||||
|
const pointWKT = `POINT(${circleData?.center[0] || 0} ${
|
||||||
|
circleData?.center[1] || 0
|
||||||
|
})`;
|
||||||
|
geometryJson = JSON.stringify({
|
||||||
|
geom_type: GeometryType.CIRCLE,
|
||||||
|
geom_poly: '',
|
||||||
|
geom_lines: '',
|
||||||
|
geom_point: pointWKT,
|
||||||
|
geom_radius: radiusInMetter,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
message.error('Loại hình học không hợp lệ!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const groupId = values[ZoneFormField.AreaId];
|
||||||
|
const provinceCode = Array.isArray(groupId)
|
||||||
|
? groupId.map((id) => groupMap[id]?.code || '').join(',')
|
||||||
|
: groupMap[groupId as string]?.code || '';
|
||||||
|
// // Prepare request body
|
||||||
|
const requestBody: SgwModel.ZoneBodyRequest = {
|
||||||
|
name: values[ZoneFormField.AreaName],
|
||||||
|
type: values[ZoneFormField.AreaType],
|
||||||
|
group_id: groupId as string,
|
||||||
|
province_code: provinceCode,
|
||||||
|
enabled: values[ZoneFormField.AreaEnabled] ?? true,
|
||||||
|
description: values[ZoneFormField.AreaDescription],
|
||||||
|
conditions: values[ZoneFormField.AreaConditions] as SgwModel.Condition[],
|
||||||
|
geom: geometryJson,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Submit body:', requestBody);
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
if (type === 'create') {
|
||||||
|
const key = 'create';
|
||||||
|
message.open({
|
||||||
|
key,
|
||||||
|
type: 'loading',
|
||||||
|
content: intl.formatMessage({ id: 'banzone.creating' }),
|
||||||
|
});
|
||||||
|
await apiCreateBanzone(requestBody);
|
||||||
|
message.open({
|
||||||
|
key,
|
||||||
|
type: 'success',
|
||||||
|
content: intl.formatMessage({ id: 'banzone.creating_success' }),
|
||||||
|
});
|
||||||
|
} else if (type === 'update' && zoneId) {
|
||||||
|
const key = 'update';
|
||||||
|
message.open({
|
||||||
|
key,
|
||||||
|
type: 'loading',
|
||||||
|
content: intl.formatMessage({ id: 'banzone.updating' }),
|
||||||
|
});
|
||||||
|
await apiUpdateBanzone(zoneId, requestBody);
|
||||||
|
message.open({
|
||||||
|
key,
|
||||||
|
type: 'success',
|
||||||
|
content: intl.formatMessage({ id: 'banzone.updating_success' }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
message.error(
|
||||||
|
type === 'update'
|
||||||
|
? intl.formatMessage({ id: 'banzone.updating_fail' })
|
||||||
|
: intl.formatMessage({ id: 'banzone.creating_fail' }),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTimeout(() => {
|
||||||
|
handleBack();
|
||||||
|
}, 1000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save zone:', error);
|
||||||
|
message.error(intl.formatMessage({ id: 'banzone.fail.save' }));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOnClickDraw = () => {
|
||||||
|
drawController.current = baseMap.current?.DrawAndModifyFeature(DATA_LAYER);
|
||||||
|
setMapActions({
|
||||||
|
isDrawing: true,
|
||||||
|
isModifying: false,
|
||||||
|
});
|
||||||
|
drawController.current.removeInteractions();
|
||||||
|
switch (shape) {
|
||||||
|
case 1:
|
||||||
|
drawController.current.drawPolygon();
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
drawController.current.drawLineString();
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
drawController.current.drawCircle();
|
||||||
|
break;
|
||||||
|
case 4:
|
||||||
|
drawController.current.drawPoint();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const polygonGeometry =
|
||||||
|
(Form.useWatch(
|
||||||
|
ZoneFormField.PolygonGeometry,
|
||||||
|
formReady && formRef.current ? formRef.current : undefined,
|
||||||
|
) as PolygonGeometry[]) || [];
|
||||||
|
|
||||||
|
const polylineData = Form.useWatch(
|
||||||
|
ZoneFormField.PolylineData,
|
||||||
|
formReady && formRef.current ? formRef.current : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const circleData = Form.useWatch(
|
||||||
|
ZoneFormField.CircleData,
|
||||||
|
formReady && formRef.current ? formRef.current : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (polygonGeometry && polygonGeometry.length > 0) {
|
||||||
|
updateMapFromForm(GeometryType.POLYGON);
|
||||||
|
}
|
||||||
|
}, [polygonGeometry, formRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (polylineData && checkValidateGeometry(polylineData)) {
|
||||||
|
updateMapFromForm(GeometryType.LINESTRING);
|
||||||
|
}
|
||||||
|
}, [polylineData, formRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (circleData) {
|
||||||
|
updateMapFromForm(GeometryType.CIRCLE);
|
||||||
|
}
|
||||||
|
}, [circleData, formRef]);
|
||||||
|
return (
|
||||||
|
<PageContainer
|
||||||
|
onBack={handleBack}
|
||||||
|
title={
|
||||||
|
type === 'create' ? (
|
||||||
|
<FormattedMessage id="banzones.create" />
|
||||||
|
) : (
|
||||||
|
<FormattedMessage id="banzones.update" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
padding: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
||||||
|
<ProCard colSpan={{ xs: 24, sm: 24, md: 10, lg: 10, xl: 10 }}>
|
||||||
|
<ProForm<ZoneFormData>
|
||||||
|
formRef={formRef}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleSubmit}
|
||||||
|
onInit={(_, form) => {
|
||||||
|
formRef.current = form;
|
||||||
|
setFormReady(true);
|
||||||
|
}}
|
||||||
|
initialValues={{
|
||||||
|
[ZoneFormField.AreaEnabled]: true,
|
||||||
|
}}
|
||||||
|
request={async () => {
|
||||||
|
if (type === 'update' && zoneId) {
|
||||||
|
try {
|
||||||
|
const zone: SgwModel.Banzone = await apiGetZoneById(zoneId);
|
||||||
|
if (!zone) return {} as ZoneFormData;
|
||||||
|
// Parse geometry (API may use `geom` or `geometry` field)
|
||||||
|
let parsedGeometry: SgwModel.Geom | undefined;
|
||||||
|
const geomRaw = (zone as any).geom ?? (zone as any).geometry;
|
||||||
|
if (geomRaw) {
|
||||||
|
try {
|
||||||
|
parsedGeometry =
|
||||||
|
typeof geomRaw === 'string'
|
||||||
|
? JSON.parse(geomRaw)
|
||||||
|
: geomRaw;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to parse geometry', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const groupId = Object.entries(groupMap).find(
|
||||||
|
([, value]) => value.code === zone.province_code,
|
||||||
|
)?.[0] as string | undefined;
|
||||||
|
// Build base form data
|
||||||
|
const formData: Partial<ZoneFormData> = {
|
||||||
|
[ZoneFormField.AreaName]: zone.name || '',
|
||||||
|
[ZoneFormField.AreaType]: zone.type ?? 1,
|
||||||
|
[ZoneFormField.AreaEnabled]: zone.enabled ?? true,
|
||||||
|
[ZoneFormField.AreaDescription]: zone.description || '',
|
||||||
|
[ZoneFormField.AreaId]: groupId || '',
|
||||||
|
[ZoneFormField.AreaConditions]:
|
||||||
|
zone.conditions as AreaCondition[],
|
||||||
|
};
|
||||||
|
// Map geometry to form fields depending on geometry type
|
||||||
|
if (parsedGeometry) {
|
||||||
|
switch (parsedGeometry.geom_type) {
|
||||||
|
case GeometryType.POLYGON: {
|
||||||
|
const polygons = parseMultiPolygonWKT(
|
||||||
|
parsedGeometry.geom_poly || '',
|
||||||
|
);
|
||||||
|
formData[ZoneFormField.PolygonGeometry] = polygons.map(
|
||||||
|
(polygon) => ({
|
||||||
|
geometry: polygon,
|
||||||
|
id: Math.random().toString(36).substring(2, 9),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GeometryType.LINESTRING: {
|
||||||
|
const polyline = parseLineStringWKT(
|
||||||
|
parsedGeometry.geom_lines || '',
|
||||||
|
);
|
||||||
|
formData[ZoneFormField.PolylineData] =
|
||||||
|
JSON.stringify(polyline);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case GeometryType.CIRCLE: {
|
||||||
|
const center = parsePointWKT(
|
||||||
|
parsedGeometry.geom_point || '',
|
||||||
|
);
|
||||||
|
formData[ZoneFormField.CircleData] = {
|
||||||
|
center: center || [0, 0],
|
||||||
|
radius: parsedGeometry.geom_radius || 0,
|
||||||
|
area: getAreaFromRadius(
|
||||||
|
parsedGeometry.geom_radius || 0,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return formData as ZoneFormData;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load zone for request:', error);
|
||||||
|
return {} as ZoneFormData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {} as ZoneFormData;
|
||||||
|
}}
|
||||||
|
submitter={{
|
||||||
|
searchConfig: {
|
||||||
|
submitText:
|
||||||
|
type === 'create'
|
||||||
|
? intl.formatMessage({ id: 'banzone.create.button.title' })
|
||||||
|
: intl.formatMessage({ id: 'banzone.update.button.title' }),
|
||||||
|
},
|
||||||
|
submitButtonProps: {
|
||||||
|
loading: loading,
|
||||||
|
},
|
||||||
|
render: (_, dom) => {
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
gap={8}
|
||||||
|
justify="center"
|
||||||
|
style={{ marginTop: 24, marginBottom: 24 }}
|
||||||
|
>
|
||||||
|
{dom}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ZoneForm formRef={formRef} shape={shape} />
|
||||||
|
<Flex align="center" gap={8} justify="end">
|
||||||
|
<Button
|
||||||
|
onClick={handleOnClickDraw}
|
||||||
|
variant={`${mapActions.isDrawing ? 'solid' : 'dashed'}`}
|
||||||
|
color={`${mapActions.isDrawing ? 'green' : 'default'}`}
|
||||||
|
icon={<GatewayOutlined />}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.map_action.draw"
|
||||||
|
defaultMessage="Draw"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleEnableModify}
|
||||||
|
variant={`${mapActions.isModifying ? 'solid' : 'dashed'}`}
|
||||||
|
color={`${mapActions.isModifying ? 'green' : 'default'}`}
|
||||||
|
icon={<EditFilled />}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="banzone.map_action.modify"
|
||||||
|
defaultMessage="Modify"
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
type="primary"
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
clearMapFeatures();
|
||||||
|
setMapActions({
|
||||||
|
isDrawing: false,
|
||||||
|
isModifying: false,
|
||||||
|
});
|
||||||
|
switch (shape) {
|
||||||
|
case GeometryType.POLYGON:
|
||||||
|
formRef.current?.setFieldValue(
|
||||||
|
ZoneFormField.PolygonGeometry,
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case GeometryType.LINESTRING:
|
||||||
|
formRef.current?.setFieldValue(
|
||||||
|
ZoneFormField.PolylineData,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case GeometryType.CIRCLE:
|
||||||
|
formRef.current?.setFieldValue(
|
||||||
|
ZoneFormField.CircleData,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
></Button>
|
||||||
|
</Flex>
|
||||||
|
</ProForm>
|
||||||
|
</ProCard>
|
||||||
|
<ProCard ghost colSpan={{ xs: 24, sm: 24, md: 14, lg: 14, xl: 14 }}>
|
||||||
|
<VietNamMap
|
||||||
|
ref={vietNamMapRef}
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
onMapReady={handleMapReady}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
</ProCard>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CreateOrUpdateBanzone;
|
||||||
216
src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/type.ts
Normal file
216
src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/type.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
export enum ZoneFormField {
|
||||||
|
AreaName = 'name',
|
||||||
|
AreaType = 'type',
|
||||||
|
AreaId = 'id',
|
||||||
|
AreaEnabled = 'enabled',
|
||||||
|
AreaDescription = 'description',
|
||||||
|
AreaConditions = 'conditions',
|
||||||
|
AreaProvinceCode = 'province_code',
|
||||||
|
PolygonGeometry = 'geometry',
|
||||||
|
PolylineData = 'polyline_data',
|
||||||
|
CircleData = 'circle_data',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZoneFormData {
|
||||||
|
[ZoneFormField.AreaName]: string;
|
||||||
|
[ZoneFormField.AreaType]: number; // API returns number
|
||||||
|
[ZoneFormField.AreaId]: string | string[] | null;
|
||||||
|
[ZoneFormField.AreaEnabled]: boolean;
|
||||||
|
[ZoneFormField.AreaDescription]?: string;
|
||||||
|
[ZoneFormField.AreaConditions]?: AreaCondition[];
|
||||||
|
[ZoneFormField.AreaProvinceCode]: string | number; // API returns string
|
||||||
|
[ZoneFormField.PolygonGeometry]: PolygonGeometry[];
|
||||||
|
[ZoneFormField.PolylineData]?: string;
|
||||||
|
[ZoneFormField.CircleData]?: CircleGeometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MonthRangeCondition = {
|
||||||
|
type: 'month_range';
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DateRangeCondition = {
|
||||||
|
type: 'date_range';
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LengthLimitCondition = {
|
||||||
|
type: 'length_limit';
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AreaCondition =
|
||||||
|
| MonthRangeCondition
|
||||||
|
| DateRangeCondition
|
||||||
|
| LengthLimitCondition;
|
||||||
|
|
||||||
|
export interface ZoneLocationState {
|
||||||
|
shape?: number;
|
||||||
|
type: 'create' | 'update';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tagPlusStyle = {
|
||||||
|
height: 22,
|
||||||
|
// background: "blue",
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PolygonGeometry = {
|
||||||
|
geometry: number[][];
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CircleGeometry = {
|
||||||
|
center: [number, number];
|
||||||
|
radius: number;
|
||||||
|
area?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Geometry type mapping with API
|
||||||
|
export enum GeometryType {
|
||||||
|
POLYGON = 1,
|
||||||
|
LINESTRING = 2,
|
||||||
|
CIRCLE = 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DrawActionType {
|
||||||
|
isDrawing: boolean;
|
||||||
|
isModifying: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse MULTIPOINT/WKT for polygon
|
||||||
|
export const parseMultiPolygonWKT = (wktString: string): number[][][] => {
|
||||||
|
if (
|
||||||
|
!wktString ||
|
||||||
|
typeof wktString !== 'string' ||
|
||||||
|
!wktString.startsWith('MULTIPOLYGON')
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = wktString.match(/MULTIPOLYGON\s*\(\(\((.*)\)\)\)/);
|
||||||
|
if (!matched) return [];
|
||||||
|
|
||||||
|
const polygons = matched[1]
|
||||||
|
.split(')),((') // chia các polygon
|
||||||
|
.map((polygonStr) =>
|
||||||
|
polygonStr
|
||||||
|
.trim()
|
||||||
|
.split(',')
|
||||||
|
.map((coordStr) => {
|
||||||
|
const [x, y] = coordStr.trim().split(' ').map(Number);
|
||||||
|
return [x, y];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return polygons;
|
||||||
|
};
|
||||||
|
|
||||||
|
// // Parse LINESTRING WKT
|
||||||
|
export const parseLineStringWKT = (wkt: string): number[][] => {
|
||||||
|
if (!wkt || !wkt.startsWith('LINESTRING')) return [];
|
||||||
|
|
||||||
|
const match = wkt.match(/LINESTRING\s*\((.*)\)/);
|
||||||
|
if (!match) return [];
|
||||||
|
|
||||||
|
return match[1].split(',').map((coordStr) => {
|
||||||
|
const [x, y] = coordStr.trim().split(' ').map(Number);
|
||||||
|
return [x, y]; // [lng, lat]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// // Parse POINT WKT
|
||||||
|
export const parsePointWKT = (wkt: string): [number, number] | null => {
|
||||||
|
if (!wkt || !wkt.startsWith('POINT')) return null;
|
||||||
|
|
||||||
|
const match = wkt.match(/POINT\s*\(([-\d.]+)\s+([-\d.]+)\)/);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
return [parseFloat(match[1]), parseFloat(match[2])]; // [lng, lat]
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format coordinates array to WKT LINESTRING
|
||||||
|
export const formatLineStringWKT = (coordinates: number[][]): string => {
|
||||||
|
const coordStr = coordinates.map((c) => `${c[0]} ${c[1]}`).join(',');
|
||||||
|
return `LINESTRING(${coordStr})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Format AreaGeometry[] to WKT MULTIPOLYGON string
|
||||||
|
export const formatPolygonGeometryToWKT = (
|
||||||
|
geometries: PolygonGeometry[],
|
||||||
|
): string => {
|
||||||
|
const polygons = geometries.map((g) => g.geometry as number[][]);
|
||||||
|
|
||||||
|
if (polygons.length === 0) return '';
|
||||||
|
|
||||||
|
const polygonStrs = polygons.map((polygon) => {
|
||||||
|
const coordStr = polygon.map((c) => `${c[0]} ${c[1]}`).join(',');
|
||||||
|
return `(${coordStr})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `MULTIPOLYGON((${polygonStrs.join('),(')}))`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const validateGeometry = (value: any) => {
|
||||||
|
if (!value || typeof value !== 'string') {
|
||||||
|
return Promise.reject('Dữ liệu không hợp lệ!');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const text = value.trim();
|
||||||
|
const formattedText = text.startsWith('[[') ? text : `[${text}]`;
|
||||||
|
const data = JSON.parse(formattedText);
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
return Promise.reject('Dữ liệu không phải mảng!');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of data) {
|
||||||
|
if (
|
||||||
|
!Array.isArray(item) ||
|
||||||
|
item.length !== 2 ||
|
||||||
|
typeof item[0] !== 'number' ||
|
||||||
|
typeof item[1] !== 'number'
|
||||||
|
) {
|
||||||
|
return Promise.reject(
|
||||||
|
'Mỗi dòng phải là [longitude, latitude] với số thực!',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve();
|
||||||
|
} catch (e) {
|
||||||
|
return Promise.reject('Định dạng JSON không hợp lệ!');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const checkValidateGeometry = (value: string) => {
|
||||||
|
if (!value || typeof value !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const text = value.trim();
|
||||||
|
const formattedText = text.startsWith('[[') ? text : `[${text}]`;
|
||||||
|
const data = JSON.parse(formattedText);
|
||||||
|
|
||||||
|
if (!Array.isArray(data)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of data) {
|
||||||
|
if (
|
||||||
|
!Array.isArray(item) ||
|
||||||
|
item.length !== 2 ||
|
||||||
|
typeof item[0] !== 'number' ||
|
||||||
|
typeof item[1] !== 'number'
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,11 +1,644 @@
|
|||||||
import React from 'react';
|
import IconFont from '@/components/IconFont';
|
||||||
|
import TreeGroup from '@/components/shared/TreeGroup';
|
||||||
|
import { DEFAULT_PAGE_SIZE } from '@/const';
|
||||||
|
import {
|
||||||
|
SGW_ROUTE_BANZONES,
|
||||||
|
SGW_ROUTE_BANZONES_LIST,
|
||||||
|
} from '@/constants/slave/sgw/routes';
|
||||||
|
|
||||||
const SGWArea: React.FC = () => {
|
import {
|
||||||
return (
|
apiGetAllBanzones,
|
||||||
<div>
|
apiRemoveBanzone,
|
||||||
<h1>Khu vực (SGW Manager)</h1>
|
} from '@/services/slave/sgw/ZoneController';
|
||||||
|
import { flattenGroupNodes } from '@/utils/slave/sgw/groupUtils';
|
||||||
|
import { formatDate } from '@/utils/slave/sgw/timeUtils';
|
||||||
|
import { DeleteOutlined, DownOutlined, EditOutlined } from '@ant-design/icons';
|
||||||
|
import {
|
||||||
|
ActionType,
|
||||||
|
ProCard,
|
||||||
|
ProColumns,
|
||||||
|
ProTable,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { FormattedMessage, history, useIntl, useModel } from '@umijs/max';
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dropdown,
|
||||||
|
Flex,
|
||||||
|
Grid,
|
||||||
|
message,
|
||||||
|
Popconfirm,
|
||||||
|
Space,
|
||||||
|
Tag,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from 'antd';
|
||||||
|
import { MenuProps } from 'antd/lib';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
const { Paragraph, Text } = Typography;
|
||||||
|
|
||||||
|
const BanZoneList = () => {
|
||||||
|
const { useBreakpoint } = Grid;
|
||||||
|
const intl = useIntl();
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const tableRef = useRef<ActionType>();
|
||||||
|
const [groupCheckedKeys, setGroupCheckedKeys] = useState<string>('');
|
||||||
|
const { groups, getGroups } = useModel('master.useGroups');
|
||||||
|
const groupFlattened = flattenGroupNodes(groups || []);
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
const [selectedRowsState, setSelectedRows] = useState<SgwModel.Banzone[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (groups === null) {
|
||||||
|
getGroups();
|
||||||
|
}
|
||||||
|
}, [groups]);
|
||||||
|
|
||||||
|
// Reload table khi groups được load
|
||||||
|
useEffect(() => {
|
||||||
|
if (groups && groups.length > 0 && tableRef.current) {
|
||||||
|
tableRef.current.reload();
|
||||||
|
}
|
||||||
|
}, [groups]);
|
||||||
|
|
||||||
|
const handleEdit = (record: SgwModel.Banzone) => {
|
||||||
|
console.log('record: ', record);
|
||||||
|
let geomType = 1; // Default: Polygon
|
||||||
|
try {
|
||||||
|
if (record.geometry) {
|
||||||
|
const geometry: SgwModel.Geom = JSON.parse(record.geometry);
|
||||||
|
geomType = geometry.geom_type || 1;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse geometry:', e);
|
||||||
|
}
|
||||||
|
history.push(`${SGW_ROUTE_BANZONES_LIST}/${record.id}`, {
|
||||||
|
type: 'update',
|
||||||
|
shape: geomType,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleDelete = async (record: SgwModel.Banzone) => {
|
||||||
|
try {
|
||||||
|
const groupID = groupFlattened.find(
|
||||||
|
(m) => m.metadata.code === record.province_code,
|
||||||
|
)?.id;
|
||||||
|
await apiRemoveBanzone(record.id || '', groupID || '');
|
||||||
|
messageApi.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'banzone.notify.delete_zone_success',
|
||||||
|
defaultMessage: 'Zone deleted successfully',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// Reload lại bảng
|
||||||
|
if (tableRef.current) {
|
||||||
|
tableRef.current.reload();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting area:', error);
|
||||||
|
messageApi.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'banzone.notify.fail',
|
||||||
|
defaultMessage: 'Delete zone failed!',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const columns: ProColumns<SgwModel.Banzone>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
title: <FormattedMessage id="banzones.name" defaultMessage="Name" />,
|
||||||
|
dataIndex: 'name',
|
||||||
|
render: (_, record) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
whiteSpace: 'normal',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paragraph
|
||||||
|
copyable
|
||||||
|
style={{ margin: 0 }}
|
||||||
|
ellipsis={{ rows: 999, tooltip: record?.name }}
|
||||||
|
>
|
||||||
|
{record?.name}
|
||||||
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
|
),
|
||||||
|
width: '15%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'group',
|
||||||
|
title: <FormattedMessage id="banzones.area" defaultMessage="Province" />,
|
||||||
|
dataIndex: 'province_code',
|
||||||
|
hideInSearch: true,
|
||||||
|
responsive: ['lg', 'md'],
|
||||||
|
ellipsis: true,
|
||||||
|
render: (_, record) => {
|
||||||
|
const matchedMember =
|
||||||
|
groupFlattened.find(
|
||||||
|
(group) => group.metadata.code === record.province_code,
|
||||||
|
) ?? null;
|
||||||
|
return (
|
||||||
|
<Text ellipsis={{ tooltip: matchedMember?.name || '-' }}>
|
||||||
|
{matchedMember?.name || '-'}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
width: '15%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'description',
|
||||||
|
title: (
|
||||||
|
<FormattedMessage id="banzones.description" defaultMessage="Mô tả" />
|
||||||
|
),
|
||||||
|
dataIndex: 'description',
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (_, record) => (
|
||||||
|
<Paragraph
|
||||||
|
ellipsis={{ rows: 2, tooltip: record?.description }}
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{record?.description || '-'}
|
||||||
|
</Paragraph>
|
||||||
|
),
|
||||||
|
width: '15%',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'type',
|
||||||
|
title: <FormattedMessage id="banzones.type" defaultMessage="Loại" />,
|
||||||
|
dataIndex: 'type',
|
||||||
|
valueType: 'select',
|
||||||
|
fieldProps: {
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'banzone.area.fishing_ban',
|
||||||
|
defaultMessage: 'Fishing Ban',
|
||||||
|
}),
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'banzone.area.move_ban',
|
||||||
|
defaultMessage: 'Movement Ban',
|
||||||
|
}),
|
||||||
|
value: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'banzone.area.safe',
|
||||||
|
defaultMessage: 'Safe Area',
|
||||||
|
}),
|
||||||
|
value: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
render: (_, record) => (
|
||||||
|
<Tag color={record.type === 1 ? '#f50' : 'orange'}>
|
||||||
|
{record.type === 1
|
||||||
|
? intl.formatMessage({
|
||||||
|
id: 'banzone.area.fishing_ban',
|
||||||
|
defaultMessage: 'Fishing Ban',
|
||||||
|
})
|
||||||
|
: intl.formatMessage({
|
||||||
|
id: 'banzone.area.move_ban',
|
||||||
|
defaultMessage: 'Movement Ban',
|
||||||
|
})}
|
||||||
|
</Tag>
|
||||||
|
),
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'conditions',
|
||||||
|
title: (
|
||||||
|
<FormattedMessage id="banzones.conditions" defaultMessage="Điều kiện" />
|
||||||
|
),
|
||||||
|
dataIndex: 'conditions',
|
||||||
|
hideInSearch: true,
|
||||||
|
render: (conditions) => {
|
||||||
|
if (!Array.isArray(conditions)) return null;
|
||||||
|
return (
|
||||||
|
<Space direction="vertical" size={4}>
|
||||||
|
{conditions.map((cond, index) => {
|
||||||
|
switch (cond.type) {
|
||||||
|
case 'month_range':
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={index}
|
||||||
|
title={`Áp dụng từ tháng ${cond.from} đến tháng ${cond.to} hàng năm`}
|
||||||
|
>
|
||||||
|
<Tag
|
||||||
|
color="geekblue"
|
||||||
|
style={{ borderRadius: 8, margin: 0 }}
|
||||||
|
>
|
||||||
|
Th.{cond.from} - Th.{cond.to}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
case 'date_range':
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={index}
|
||||||
|
title={`Áp dụng từ ${formatDate(
|
||||||
|
cond.from,
|
||||||
|
)} đến ${formatDate(cond.to)}`}
|
||||||
|
>
|
||||||
|
<Tag color="green" style={{ borderRadius: 8, margin: 0 }}>
|
||||||
|
{formatDate(cond.from)} → {formatDate(cond.to)}
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
case 'length_limit':
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={index}
|
||||||
|
title={`Tàu từ ${cond.min} đến ${cond.max} mét`}
|
||||||
|
>
|
||||||
|
<Tag color="cyan" style={{ borderRadius: 8, margin: 0 }}>
|
||||||
|
{cond.min}-{cond.max}m
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'enabled',
|
||||||
|
title: (
|
||||||
|
<FormattedMessage id="banzones.state" defaultMessage="Trạng thái" />
|
||||||
|
),
|
||||||
|
dataIndex: 'enabled',
|
||||||
|
valueType: 'select',
|
||||||
|
fieldProps: {
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'banzone.is_enable',
|
||||||
|
defaultMessage: 'Enabled',
|
||||||
|
}),
|
||||||
|
value: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'banzone.is_unenabled',
|
||||||
|
defaultMessage: 'Disabled',
|
||||||
|
}),
|
||||||
|
value: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
hideInSearch: false,
|
||||||
|
responsive: ['lg', 'md'],
|
||||||
|
render: (_, record) => {
|
||||||
|
return (
|
||||||
|
<Tag color={record.enabled === true ? '#08CB00' : '#DCDCDC'}>
|
||||||
|
{record.enabled === true
|
||||||
|
? intl.formatMessage({
|
||||||
|
id: 'banzone.is_enable',
|
||||||
|
defaultMessage: 'Enabled',
|
||||||
|
})
|
||||||
|
: intl.formatMessage({
|
||||||
|
id: 'banzone.is_unenabled',
|
||||||
|
defaultMessage: 'Disabled',
|
||||||
|
})}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: <FormattedMessage id="banzones.action" defaultMessage="Action" />,
|
||||||
|
hideInSearch: true,
|
||||||
|
width: 120,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, record) => [
|
||||||
|
<Space key="actions">
|
||||||
|
<Button
|
||||||
|
key="edit"
|
||||||
|
type="primary"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleEdit(record)}
|
||||||
|
></Button>
|
||||||
|
<Popconfirm
|
||||||
|
key="delete"
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'common.delete_confirm',
|
||||||
|
defaultMessage: 'Confirm delete?',
|
||||||
|
})}
|
||||||
|
description={`${intl.formatMessage({
|
||||||
|
id: 'banzone.notify.delete_zone_confirm',
|
||||||
|
defaultMessage: 'Are you sure you want to delete this zone',
|
||||||
|
})} "${record.name}"?`}
|
||||||
|
onConfirm={() => handleDelete(record)}
|
||||||
|
okText={intl.formatMessage({
|
||||||
|
id: 'common.delete',
|
||||||
|
defaultMessage: 'Delete',
|
||||||
|
})}
|
||||||
|
cancelText={intl.formatMessage({
|
||||||
|
id: 'common.cancel',
|
||||||
|
defaultMessage: 'Cancel',
|
||||||
|
})}
|
||||||
|
okType="danger"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
danger
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
size="small"
|
||||||
|
></Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const items: Required<MenuProps>['items'] = [
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'banzone.polygon',
|
||||||
|
defaultMessage: 'Polygon',
|
||||||
|
}),
|
||||||
|
onClick: () => {
|
||||||
|
history.push(SGW_ROUTE_BANZONES, {
|
||||||
|
shape: 1,
|
||||||
|
type: 'create',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
key: '0',
|
||||||
|
icon: <IconFont type="icon-polygon" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'banzone.polyline',
|
||||||
|
defaultMessage: 'Polyline',
|
||||||
|
}),
|
||||||
|
key: '1',
|
||||||
|
onClick: () => {
|
||||||
|
history.push(SGW_ROUTE_BANZONES, {
|
||||||
|
shape: 2,
|
||||||
|
type: 'create',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: <IconFont type="icon-polyline" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'banzone.circle',
|
||||||
|
defaultMessage: 'Circle',
|
||||||
|
}),
|
||||||
|
key: '3',
|
||||||
|
onClick: () => {
|
||||||
|
history.push(SGW_ROUTE_BANZONES, {
|
||||||
|
shape: 3,
|
||||||
|
type: 'create',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
icon: <IconFont type="icon-circle" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const deleteMultipleBanzones = async (records: SgwModel.Banzone[]) => {
|
||||||
|
const key = 'deleteMultiple';
|
||||||
|
messageApi.open({
|
||||||
|
key,
|
||||||
|
type: 'loading',
|
||||||
|
content: intl.formatMessage({
|
||||||
|
id: 'common.deleting',
|
||||||
|
defaultMessage: 'Deleting...',
|
||||||
|
}),
|
||||||
|
duration: 0,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
for (const record of records) {
|
||||||
|
const groupID = groupFlattened.find(
|
||||||
|
(m) => m.metadata.code === record.province_code,
|
||||||
|
)?.id;
|
||||||
|
|
||||||
|
await apiRemoveBanzone(record.id || '', groupID || '');
|
||||||
|
}
|
||||||
|
messageApi.open({
|
||||||
|
key,
|
||||||
|
type: 'success',
|
||||||
|
content: `Đã xoá thành công ${records.length} khu vực`,
|
||||||
|
duration: 2,
|
||||||
|
});
|
||||||
|
tableRef.current?.reload();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting area:', error);
|
||||||
|
messageApi.open({
|
||||||
|
key,
|
||||||
|
type: 'error',
|
||||||
|
content: intl.formatMessage({
|
||||||
|
id: 'banzone.notify.fail',
|
||||||
|
defaultMessage: 'Delete zone failed!',
|
||||||
|
}),
|
||||||
|
duration: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{contextHolder}
|
||||||
|
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
||||||
|
<ProCard colSpan={{ xs: 24, sm: 24, md: 4, lg: 4, xl: 4 }}>
|
||||||
|
<TreeGroup
|
||||||
|
multiple
|
||||||
|
onSelected={(value) => {
|
||||||
|
// Convert group IDs to province codes string
|
||||||
|
const selectedIds = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: value
|
||||||
|
? [value]
|
||||||
|
: [];
|
||||||
|
const provinceCodes =
|
||||||
|
selectedIds.length > 0
|
||||||
|
? selectedIds
|
||||||
|
.reduce((codes: string[], id) => {
|
||||||
|
const group = groupFlattened.find((g) => g.id === id);
|
||||||
|
if (group?.metadata?.code) {
|
||||||
|
codes.push(group.metadata.code);
|
||||||
|
}
|
||||||
|
return codes;
|
||||||
|
}, [])
|
||||||
|
.join(',')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
setGroupCheckedKeys(provinceCodes);
|
||||||
|
tableRef.current?.reload();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
<ProCard colSpan={{ xs: 24, sm: 24, md: 20, lg: 20, xl: 20 }}>
|
||||||
|
<ProTable<SgwModel.Banzone>
|
||||||
|
tableLayout="fixed"
|
||||||
|
scroll={{ x: 1000 }}
|
||||||
|
actionRef={tableRef}
|
||||||
|
columns={columns}
|
||||||
|
pagination={{
|
||||||
|
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['5', '10', '15', '20'],
|
||||||
|
showTotal: (total, range) =>
|
||||||
|
`${range[0]}-${range[1]}
|
||||||
|
${intl.formatMessage({
|
||||||
|
id: 'common.of',
|
||||||
|
defaultMessage: 'of',
|
||||||
|
})}
|
||||||
|
${total} ${intl.formatMessage({
|
||||||
|
id: 'banzones.title',
|
||||||
|
defaultMessage: 'zones',
|
||||||
|
})}`,
|
||||||
|
}}
|
||||||
|
request={async (params) => {
|
||||||
|
const { current, pageSize, name, type, enabled } = params;
|
||||||
|
|
||||||
|
// Nếu chưa có groups, đợi
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
|
||||||
|
|
||||||
|
const groupFalttened = flattenGroupNodes(groups || []);
|
||||||
|
const groupId =
|
||||||
|
groupCheckedKeys ||
|
||||||
|
groupFalttened
|
||||||
|
.map((group) => group.metadata.code)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(',') + ',';
|
||||||
|
|
||||||
|
if (!groupId || groupId === ',') {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: SgwModel.SearchZonePaginationBody = {
|
||||||
|
name: name || '',
|
||||||
|
order: 'name',
|
||||||
|
dir: 'asc',
|
||||||
|
limit: pageSize,
|
||||||
|
offset: offset,
|
||||||
|
metadata: {
|
||||||
|
province_code: groupId,
|
||||||
|
...(type ? { type: Number(type) } : {}), // nếu có type thì thêm vào
|
||||||
|
...(enabled !== undefined ? { enabled } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await apiGetAllBanzones(body);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: resp?.banzones || [],
|
||||||
|
total: resp?.total || 0,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Query banzones failed:', error);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rowKey="id"
|
||||||
|
options={{
|
||||||
|
search: false,
|
||||||
|
setting: false,
|
||||||
|
density: false,
|
||||||
|
reload: true,
|
||||||
|
}}
|
||||||
|
search={{
|
||||||
|
layout: 'vertical',
|
||||||
|
defaultCollapsed: false,
|
||||||
|
}}
|
||||||
|
dateFormatter="string"
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: selectedRowsState?.map((row) => row?.id ?? ''),
|
||||||
|
onChange: (_, selectedRows) => {
|
||||||
|
setSelectedRows(selectedRows);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
tableAlertRender={({ selectedRowKeys }) => (
|
||||||
|
<div>Đã chọn {selectedRowKeys.length} mục</div>
|
||||||
|
)}
|
||||||
|
tableAlertOptionRender={({ selectedRows, onCleanSelected }) => {
|
||||||
|
return (
|
||||||
|
<Flex gap={5}>
|
||||||
|
<Popconfirm
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'common.notification',
|
||||||
|
defaultMessage: 'Thông báo',
|
||||||
|
})}
|
||||||
|
description={`Bạn muốn xoá hết ${selectedRows.length} khu vực này?`}
|
||||||
|
onConfirm={() => {
|
||||||
|
deleteMultipleBanzones(selectedRows);
|
||||||
|
}}
|
||||||
|
okText={intl.formatMessage({
|
||||||
|
id: 'common.sure',
|
||||||
|
defaultMessage: 'Chắc chắn',
|
||||||
|
})}
|
||||||
|
cancelText={intl.formatMessage({
|
||||||
|
id: 'common.no',
|
||||||
|
defaultMessage: 'Không',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button type="primary" danger>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'common.delete',
|
||||||
|
defaultMessage: 'Xóa',
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
|
||||||
|
<Button color="cyan" variant="text" onClick={onCleanSelected}>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'common.cancel',
|
||||||
|
defaultMessage: 'Bỏ chọn',
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items }}
|
||||||
|
trigger={['click']}
|
||||||
|
key="toolbar-dropdown"
|
||||||
|
>
|
||||||
|
<Button type="primary">
|
||||||
|
<Space>
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: 'banzones.create',
|
||||||
|
defaultMessage: 'Tạo khu vực',
|
||||||
|
})}
|
||||||
|
<DownOutlined />
|
||||||
|
</Space>
|
||||||
|
</Button>
|
||||||
|
</Dropdown>,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
</ProCard>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SGWArea;
|
export default BanZoneList;
|
||||||
|
|||||||
399
src/pages/Slave/SGW/Manager/Fish/component/AddOrUpdateFish.tsx
Normal file
399
src/pages/Slave/SGW/Manager/Fish/component/AddOrUpdateFish.tsx
Normal file
@@ -0,0 +1,399 @@
|
|||||||
|
import { HTTPSTATUS } from '@/constants';
|
||||||
|
import {
|
||||||
|
apiCreateFishSpecies,
|
||||||
|
apiUpdateFishSpecies,
|
||||||
|
} from '@/services/slave/sgw/FishController';
|
||||||
|
import {
|
||||||
|
apiDeletePhoto,
|
||||||
|
apiUploadPhoto,
|
||||||
|
} from '@/services/slave/sgw/PhotoController';
|
||||||
|
import {
|
||||||
|
ModalForm,
|
||||||
|
ProForm,
|
||||||
|
ProFormInstance,
|
||||||
|
ProFormSelect,
|
||||||
|
ProFormText,
|
||||||
|
ProFormTextArea,
|
||||||
|
ProFormUploadButton,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { useIntl } from '@umijs/max';
|
||||||
|
import { MessageInstance } from 'antd/es/message/interface';
|
||||||
|
import type { UploadFile } from 'antd/es/upload/interface';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export type AddOrUpdateFishProps = {
|
||||||
|
type: 'create' | 'update';
|
||||||
|
fish?: SgwModel.Fish;
|
||||||
|
isOpen?: boolean;
|
||||||
|
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
message: MessageInstance;
|
||||||
|
onReload?: (isSuccess: boolean) => void;
|
||||||
|
};
|
||||||
|
const AddOrUpdateFish = ({
|
||||||
|
type,
|
||||||
|
fish,
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
message,
|
||||||
|
onReload,
|
||||||
|
}: AddOrUpdateFishProps) => {
|
||||||
|
const formRef = useRef<ProFormInstance<SgwModel.Fish>>();
|
||||||
|
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||||
|
const [originalFileList, setOriginalFileList] = useState<UploadFile[]>([]);
|
||||||
|
const intl = useIntl();
|
||||||
|
// Check ảnh có thay đổi so với ban đầu không
|
||||||
|
const hasImageChanged = () => {
|
||||||
|
const currentHasImage = fileList.length > 0;
|
||||||
|
const originalHasImage = originalFileList.length > 0;
|
||||||
|
|
||||||
|
// Nếu số lượng ảnh khác nhau → có thay đổi
|
||||||
|
if (currentHasImage !== originalHasImage) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu cả 2 đều rỗng → không thay đổi
|
||||||
|
if (!currentHasImage) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nếu có ảnh, check xem file có phải là file mới upload không
|
||||||
|
// (file gốc có uid = '-1', file mới upload có uid khác)
|
||||||
|
const currentFile = fileList[0];
|
||||||
|
const isOriginalImage =
|
||||||
|
currentFile.uid === '-1' && currentFile.status === 'done';
|
||||||
|
|
||||||
|
return !isOriginalImage;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalForm<SgwModel.Fish>
|
||||||
|
key={fish?.id || 'new'}
|
||||||
|
open={isOpen}
|
||||||
|
formRef={formRef}
|
||||||
|
title={
|
||||||
|
type === 'create'
|
||||||
|
? intl.formatMessage({
|
||||||
|
id: 'fish.create.title',
|
||||||
|
defaultMessage: 'Thêm cá mới',
|
||||||
|
})
|
||||||
|
: intl.formatMessage({
|
||||||
|
id: 'fish.update.title',
|
||||||
|
defaultMessage: 'Cập nhật cá',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
layout="vertical"
|
||||||
|
modalProps={{
|
||||||
|
destroyOnHidden: true,
|
||||||
|
}}
|
||||||
|
request={async () => {
|
||||||
|
if (type === 'update' && fish) {
|
||||||
|
return fish;
|
||||||
|
}
|
||||||
|
setFileList([]);
|
||||||
|
setOriginalFileList([]);
|
||||||
|
return {};
|
||||||
|
}}
|
||||||
|
onFinish={async (values) => {
|
||||||
|
// 1. Cập nhật thông tin cá
|
||||||
|
if (type === 'create') {
|
||||||
|
// TODO: Gọi API tạo cá mới
|
||||||
|
// const result = await apiCreateFish(values);
|
||||||
|
console.log('Create fish:', values);
|
||||||
|
try {
|
||||||
|
const resp = await apiCreateFishSpecies(values);
|
||||||
|
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||||
|
message.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'fish.create.success',
|
||||||
|
defaultMessage: 'Tạo cá thành công',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
onReload?.(true);
|
||||||
|
const id = resp.data.name_ids![0];
|
||||||
|
if (fileList.length > 0 && fileList[0].originFileObj && id) {
|
||||||
|
// TODO: Sau khi có result.id từ API create
|
||||||
|
// await apiUploadPhoto('fish', result.id, fileList[0].originFileObj);
|
||||||
|
console.log('Upload photo for new fish');
|
||||||
|
try {
|
||||||
|
const resp = await apiUploadPhoto(
|
||||||
|
'fish',
|
||||||
|
id.toString(),
|
||||||
|
fileList[0].originFileObj,
|
||||||
|
);
|
||||||
|
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||||
|
message.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'fish.create.image.success',
|
||||||
|
defaultMessage: 'Thêm ảnh cá thành công',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
onReload?.(true);
|
||||||
|
} else {
|
||||||
|
throw new Error('Thêm ảnh thất bại');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'fish.create.image.fail',
|
||||||
|
defaultMessage: 'Thêm ảnh cá thất bại',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Tạo cá thất bại');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'fish.create.fail',
|
||||||
|
defaultMessage: 'Tạo cá thất bại',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 2. Upload ảnh (nếu có chọn ảnh)
|
||||||
|
onReload?.(true);
|
||||||
|
} else {
|
||||||
|
// TODO: Gọi API cập nhật cá
|
||||||
|
// await apiUpdateFish(fish!.id!, values);
|
||||||
|
console.log('Update fish:', fish?.id, values);
|
||||||
|
|
||||||
|
// Check nếu dữ liệu có thay đổi so với ban đầu
|
||||||
|
const hasDataChanged =
|
||||||
|
fish!.name !== values.name ||
|
||||||
|
fish!.scientific_name !== values.scientific_name ||
|
||||||
|
fish!.group_name !== values.group_name ||
|
||||||
|
fish!.rarity_level !== values.rarity_level ||
|
||||||
|
fish!.note !== values.note;
|
||||||
|
|
||||||
|
if (hasDataChanged) {
|
||||||
|
try {
|
||||||
|
const body = { ...values, id: fish!.id! };
|
||||||
|
const resp = await apiUpdateFishSpecies(body);
|
||||||
|
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||||
|
message.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'fish.update.success',
|
||||||
|
defaultMessage: 'Cập nhật cá thành công',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
onReload?.(true);
|
||||||
|
} else {
|
||||||
|
throw new Error('Cập nhật cá thất bại');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'fish.update.fail',
|
||||||
|
defaultMessage: 'Cập nhật cá thất bại',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Dữ liệu không thay đổi, bỏ qua API update');
|
||||||
|
}
|
||||||
|
// 2. Upload ảnh (chỉ khi ảnh có thay đổi)
|
||||||
|
if (hasImageChanged()) {
|
||||||
|
if (fileList.length > 0 && fileList[0].originFileObj) {
|
||||||
|
// TODO: Upload ảnh mới
|
||||||
|
// await apiUploadPhoto('fish', fish!.id!, fileList[0].originFileObj);
|
||||||
|
try {
|
||||||
|
const resp = await apiUploadPhoto(
|
||||||
|
'fish',
|
||||||
|
fish!.id!.toString(),
|
||||||
|
fileList[0].originFileObj,
|
||||||
|
);
|
||||||
|
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||||
|
message.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'fish.update.image.success',
|
||||||
|
defaultMessage: 'Cập nhật ảnh cá thành công',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
onReload?.(true);
|
||||||
|
} else {
|
||||||
|
throw new Error('Cập nhật ảnh thất bại');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'fish.update.image.fail',
|
||||||
|
defaultMessage: 'Cập nhật ảnh cá thất bại',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// TODO: Xóa ảnh (nếu có API delete)
|
||||||
|
console.log('Remove photo');
|
||||||
|
const resp = await apiDeletePhoto('fish', fish!.id!);
|
||||||
|
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||||
|
message.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'fish.delete.image.success',
|
||||||
|
defaultMessage: 'Xóa ảnh cá thành công',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
onReload?.(true);
|
||||||
|
} else {
|
||||||
|
message.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'fish.delete.image.fail',
|
||||||
|
defaultMessage: 'Xóa ảnh cá thất bại',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{type === 'create' && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ProFormUploadButton
|
||||||
|
name="upload"
|
||||||
|
label={null}
|
||||||
|
title="Chọn ảnh"
|
||||||
|
accept="image/*"
|
||||||
|
max={1}
|
||||||
|
transform={(value) => ({ upload: value })}
|
||||||
|
fieldProps={{
|
||||||
|
onChange(info) {
|
||||||
|
setFileList(info.fileList);
|
||||||
|
},
|
||||||
|
listType: 'picture-card',
|
||||||
|
fileList: fileList,
|
||||||
|
onRemove: () => {
|
||||||
|
setFileList([]);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ProForm.Group>
|
||||||
|
<ProFormText
|
||||||
|
name="name"
|
||||||
|
width="md"
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'common.name',
|
||||||
|
defaultMessage: 'Tên',
|
||||||
|
})}
|
||||||
|
tooltip={intl.formatMessage({
|
||||||
|
id: 'fish.name.tooltip',
|
||||||
|
defaultMessage: 'Tên loài cá',
|
||||||
|
})}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'fish.name.placeholder',
|
||||||
|
defaultMessage: 'Nhập tên cá',
|
||||||
|
})}
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
required: true,
|
||||||
|
message: intl.formatMessage({
|
||||||
|
id: 'fish.name.required',
|
||||||
|
defaultMessage: 'Tên cá không được để trống',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<ProFormText
|
||||||
|
name="scientific_name"
|
||||||
|
width="md"
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'fish.specific_name',
|
||||||
|
defaultMessage: 'Tên khoa học',
|
||||||
|
})}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'fish.specific_name.placeholder',
|
||||||
|
defaultMessage: 'Nhập tên khoa học',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ProForm.Group>
|
||||||
|
<ProForm.Group>
|
||||||
|
<ProFormText
|
||||||
|
name="group_name"
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'fish.fish_group',
|
||||||
|
defaultMessage: 'Nhóm',
|
||||||
|
})}
|
||||||
|
width="md"
|
||||||
|
tooltip={intl.formatMessage({
|
||||||
|
id: 'fish.fish_group.tooltip',
|
||||||
|
defaultMessage: 'Nhóm cá',
|
||||||
|
})}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'fish.fish_group.placeholder',
|
||||||
|
defaultMessage: 'Nhập nhóm cá',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ProFormSelect
|
||||||
|
name="rarity_level"
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'fish.rarity',
|
||||||
|
defaultMessage: 'Độ hiếm',
|
||||||
|
})}
|
||||||
|
width="md"
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'fish.rarity.placeholder',
|
||||||
|
defaultMessage: 'Chọn độ hiếm',
|
||||||
|
})}
|
||||||
|
options={[
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'fish.rarity.normal',
|
||||||
|
defaultMessage: 'Phổ biến',
|
||||||
|
}),
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'fish.rarity.sensitive',
|
||||||
|
defaultMessage: 'Dễ bị tổn thương',
|
||||||
|
}),
|
||||||
|
value: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'fish.rarity.near_threatened',
|
||||||
|
defaultMessage: 'Gần bị đe dọa',
|
||||||
|
}),
|
||||||
|
value: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: intl.formatMessage({
|
||||||
|
id: 'fish.rarity.endangered',
|
||||||
|
defaultMessage: 'Nguy cấp',
|
||||||
|
}),
|
||||||
|
value: 4,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</ProForm.Group>
|
||||||
|
<ProFormTextArea
|
||||||
|
name="note"
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: 'common.description',
|
||||||
|
defaultMessage: 'Ghi chú',
|
||||||
|
})}
|
||||||
|
placeholder={intl.formatMessage({
|
||||||
|
id: 'common.description.placeholder',
|
||||||
|
defaultMessage: 'Nhập ghi chú',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</ModalForm>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddOrUpdateFish;
|
||||||
66
src/pages/Slave/SGW/Manager/Fish/component/FishImage.tsx
Normal file
66
src/pages/Slave/SGW/Manager/Fish/component/FishImage.tsx
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { HTTPSTATUS } from '@/constants';
|
||||||
|
import { apiGetPhoto } from '@/services/slave/sgw/PhotoController';
|
||||||
|
import { Image, Spin } from 'antd';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export const FishImage = ({
|
||||||
|
fishId,
|
||||||
|
alt,
|
||||||
|
isReload,
|
||||||
|
}: {
|
||||||
|
fishId: string;
|
||||||
|
alt: string;
|
||||||
|
isReload: boolean;
|
||||||
|
}) => {
|
||||||
|
const [url, setUrl] = useState<string>('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const objectUrlRef = useRef<string>('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const fetchImage = async () => {
|
||||||
|
try {
|
||||||
|
const resp = await apiGetPhoto('fish', fishId);
|
||||||
|
let objectUrl = '';
|
||||||
|
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||||
|
const blob = new Blob([resp.data], { type: 'image/jpeg' });
|
||||||
|
objectUrl = URL.createObjectURL(blob);
|
||||||
|
objectUrlRef.current = objectUrl;
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to fetch image');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setUrl(objectUrl);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// console.log('Error: ', error);
|
||||||
|
setUrl('');
|
||||||
|
if (isMounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchImage();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
if (objectUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(objectUrlRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [fishId, isReload]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Spin size="small" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return <span>-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Image height={50} width={50} src={url} alt={alt} />;
|
||||||
|
};
|
||||||
@@ -1,11 +1,350 @@
|
|||||||
import React from 'react';
|
import PhotoActionModal from '@/components/shared/PhotoActionModal';
|
||||||
|
import { DEFAULT_PAGE_SIZE, HTTPSTATUS } from '@/constants';
|
||||||
|
import {
|
||||||
|
apiDeleteFishSpecies,
|
||||||
|
apiGetFishSpecies,
|
||||||
|
} from '@/services/slave/sgw/FishController';
|
||||||
|
import { getRarityById } from '@/utils/slave/sgw/fishRarity';
|
||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
PictureOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
|
||||||
|
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||||
|
import { Button, Flex, message, Popconfirm, Tag, theme, Tooltip } from 'antd';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import AddOrUpdateFish from './component/AddOrUpdateFish';
|
||||||
|
import { FishImage } from './component/FishImage';
|
||||||
|
|
||||||
|
const FishList = () => {
|
||||||
|
const tableRef = useRef<ActionType>();
|
||||||
|
const intl = useIntl();
|
||||||
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
const [showAddOrUpdateModal, setShowAddOrUpdateModal] =
|
||||||
|
useState<boolean>(false);
|
||||||
|
const [fishSelected, setFishSelected] = useState<SgwModel.Fish | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
const [fishPhotoModalOpen, setFishPhotoModalOpen] = useState<boolean>(false);
|
||||||
|
const [fishID, setFishID] = useState<number | undefined>(undefined);
|
||||||
|
const [isReloadImage, setIsReloadImage] = useState<boolean>(false);
|
||||||
|
const token = theme.useToken();
|
||||||
|
const getColorByRarityLevel = (level: number) => {
|
||||||
|
switch (level) {
|
||||||
|
case 2:
|
||||||
|
return token.token.yellow;
|
||||||
|
case 3:
|
||||||
|
return token.token.orange;
|
||||||
|
case 4:
|
||||||
|
return token.token.colorError;
|
||||||
|
case 5:
|
||||||
|
return '#FF6347';
|
||||||
|
case 6:
|
||||||
|
return '#FF4500';
|
||||||
|
case 7:
|
||||||
|
return '#FF0000';
|
||||||
|
case 8:
|
||||||
|
return '#8B0000';
|
||||||
|
default:
|
||||||
|
return token.token.green;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteFish = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const resp = await apiDeleteFishSpecies(id);
|
||||||
|
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
|
||||||
|
messageApi.success(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'fish.delete.success',
|
||||||
|
defaultMessage: 'Successfully deleted fish',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
tableRef.current?.reload();
|
||||||
|
} else {
|
||||||
|
throw new Error('Xóa cá thất bại');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
messageApi.error(
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'fish.delete.fail',
|
||||||
|
defaultMessage: 'Failed to delete fish',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ProColumns<SgwModel.Fish>[] = [
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
id: 'common.name',
|
||||||
|
defaultMessage: 'Name',
|
||||||
|
}),
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
copyable: true,
|
||||||
|
render: (dom, entity) => {
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'fish.specific_name',
|
||||||
|
defaultMessage: 'Scientific Name',
|
||||||
|
}) +
|
||||||
|
': ' +
|
||||||
|
entity.scientific_name
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{dom}
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
id: 'common.image',
|
||||||
|
defaultMessage: 'Image',
|
||||||
|
}),
|
||||||
|
dataIndex: 'id',
|
||||||
|
key: 'id',
|
||||||
|
hideInSearch: true,
|
||||||
|
// valueType: 'image',
|
||||||
|
render: (_, entity) => {
|
||||||
|
return (
|
||||||
|
<FishImage
|
||||||
|
fishId={String(entity.id || '')}
|
||||||
|
alt={entity.name || ''}
|
||||||
|
isReload={isReloadImage}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
id: 'fish.fish_group',
|
||||||
|
defaultMessage: 'Group',
|
||||||
|
}),
|
||||||
|
dataIndex: 'group_name',
|
||||||
|
key: 'group_name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
id: 'fish.rarity',
|
||||||
|
defaultMessage: 'Rarity',
|
||||||
|
}),
|
||||||
|
dataIndex: 'rarity_level',
|
||||||
|
key: 'rarity_level',
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
1: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'fish.rarity.normal',
|
||||||
|
defaultMessage: 'Common',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'fish.rarity.sensitive',
|
||||||
|
defaultMessage: 'Sensitive',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
3: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'fish.rarity.near_threatened',
|
||||||
|
defaultMessage: 'Near Threatened',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
4: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'fish.rarity.endangered',
|
||||||
|
defaultMessage: 'Endangered',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_, entity) => {
|
||||||
|
const rarity = getRarityById(entity.rarity_level || 1);
|
||||||
|
return (
|
||||||
|
<Tag color={getColorByRarityLevel(entity.rarity_level || 1)}>
|
||||||
|
{rarity ||
|
||||||
|
intl.formatMessage({
|
||||||
|
id: 'common.undefined',
|
||||||
|
defaultMessage: 'Undefined',
|
||||||
|
})}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
id: 'common.note',
|
||||||
|
defaultMessage: 'Note',
|
||||||
|
}),
|
||||||
|
dataIndex: 'note',
|
||||||
|
hideInSearch: true,
|
||||||
|
key: 'note',
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
id: 'common.actions',
|
||||||
|
defaultMessage: 'Actions',
|
||||||
|
}),
|
||||||
|
dataIndex: 'actions',
|
||||||
|
key: 'actions',
|
||||||
|
hideInSearch: true,
|
||||||
|
align: 'center',
|
||||||
|
render: (_, entity) => {
|
||||||
|
return (
|
||||||
|
<Flex align="center" justify="center" gap={8}>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
onClick={() => {
|
||||||
|
setFishSelected(entity);
|
||||||
|
setShowAddOrUpdateModal(true);
|
||||||
|
}}
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
></Button>
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
onClick={async () => {
|
||||||
|
setFishID(entity.id);
|
||||||
|
setFishPhotoModalOpen(true);
|
||||||
|
}}
|
||||||
|
icon={<PictureOutlined />}
|
||||||
|
></Button>
|
||||||
|
<Popconfirm
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: 'fish.delete_confirm',
|
||||||
|
defaultMessage:
|
||||||
|
'Are you sure you want to delete this fish species?',
|
||||||
|
})}
|
||||||
|
onConfirm={() => handleDeleteFish(entity.id?.toString() || '')}
|
||||||
|
okText={intl.formatMessage({
|
||||||
|
id: 'common.yes',
|
||||||
|
defaultMessage: 'Yes',
|
||||||
|
})}
|
||||||
|
cancelText={intl.formatMessage({
|
||||||
|
id: 'common.no',
|
||||||
|
defaultMessage: 'No',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Button type="text" danger icon={<DeleteOutlined />}></Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const SGWFish: React.FC = () => {
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Cá (SGW Manager)</h1>
|
{contextHolder}
|
||||||
|
<AddOrUpdateFish
|
||||||
|
type={fishSelected ? 'update' : 'create'}
|
||||||
|
isOpen={showAddOrUpdateModal}
|
||||||
|
setIsOpen={setShowAddOrUpdateModal}
|
||||||
|
fish={fishSelected}
|
||||||
|
message={messageApi}
|
||||||
|
onReload={(isSuccess) => {
|
||||||
|
if (isSuccess) {
|
||||||
|
tableRef.current?.reload();
|
||||||
|
setIsReloadImage((prev) => !prev);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PhotoActionModal
|
||||||
|
key={fishID ?? 'none'}
|
||||||
|
isOpen={fishPhotoModalOpen}
|
||||||
|
setIsOpen={setFishPhotoModalOpen}
|
||||||
|
type={'fish'}
|
||||||
|
id={fishID!}
|
||||||
|
hasSubPhotos={true}
|
||||||
|
/>
|
||||||
|
<ProTable<SgwModel.Fish>
|
||||||
|
actionRef={tableRef}
|
||||||
|
rowKey="id"
|
||||||
|
size="large"
|
||||||
|
columns={columns}
|
||||||
|
search={{
|
||||||
|
defaultCollapsed: false,
|
||||||
|
}}
|
||||||
|
columnEmptyText="-"
|
||||||
|
pagination={{
|
||||||
|
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['5', '10', '15', '20'],
|
||||||
|
showTotal: (total, range) =>
|
||||||
|
`${range[0]}-${range[1]}
|
||||||
|
${intl.formatMessage({
|
||||||
|
id: 'common.of',
|
||||||
|
defaultMessage: 'of',
|
||||||
|
})}
|
||||||
|
${total} ${intl.formatMessage({
|
||||||
|
id: 'fish.name',
|
||||||
|
defaultMessage: 'fishes',
|
||||||
|
})}`,
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
search: false,
|
||||||
|
setting: false,
|
||||||
|
density: false,
|
||||||
|
reload: true,
|
||||||
|
}}
|
||||||
|
toolBarRender={() => [
|
||||||
|
<Button
|
||||||
|
color="cyan"
|
||||||
|
variant="outlined"
|
||||||
|
key="add-fish"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
size="middle"
|
||||||
|
onClick={() => {
|
||||||
|
setFishSelected(undefined);
|
||||||
|
setShowAddOrUpdateModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id="fish.create.title"
|
||||||
|
defaultMessage="Add Fish Species"
|
||||||
|
/>
|
||||||
|
</Button>,
|
||||||
|
]}
|
||||||
|
request={async (params) => {
|
||||||
|
const { current, pageSize, name, group_name, rarity_level } = params;
|
||||||
|
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
|
||||||
|
const body: SgwModel.SearchFishPaginationBody = {
|
||||||
|
name: name,
|
||||||
|
order: 'name',
|
||||||
|
limit: pageSize,
|
||||||
|
offset: offset,
|
||||||
|
dir: 'desc',
|
||||||
|
};
|
||||||
|
if (group_name || rarity_level) body.metadata = {};
|
||||||
|
if (group_name && body.metadata) {
|
||||||
|
body.metadata.group_name = group_name;
|
||||||
|
}
|
||||||
|
if (rarity_level && body.metadata) {
|
||||||
|
body.metadata.rarity_level = Number(rarity_level);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await apiGetFishSpecies(body);
|
||||||
|
return {
|
||||||
|
data: res.fishes,
|
||||||
|
total: res.total,
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
success: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SGWFish;
|
export default FishList;
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const ShipDetail = ({
|
|||||||
|
|
||||||
let shipOwner:
|
let shipOwner:
|
||||||
| MasterModel.UserResponse
|
| MasterModel.UserResponse
|
||||||
| MasterModel.ProfileResponse
|
| MasterModel.UserResponse
|
||||||
| null = null;
|
| null = null;
|
||||||
if (resp?.owner_id) {
|
if (resp?.owner_id) {
|
||||||
if (initialState?.currentUserProfile?.metadata?.user_type === 'admin') {
|
if (initialState?.currentUserProfile?.metadata?.user_type === 'admin') {
|
||||||
@@ -72,8 +72,8 @@ const ShipDetail = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const photoData = await apiGetPhoto('ship', resp.id || '');
|
const photoResponse = await apiGetPhoto('ship', resp.id || '');
|
||||||
const blob = new Blob([photoData], { type: 'image/jpeg' });
|
const blob = new Blob([photoResponse.data], { type: 'image/jpeg' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
setShipImage(url);
|
setShipImage(url);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ const MapPage = () => {
|
|||||||
? ''
|
? ''
|
||||||
: [stateCriticalQuery, stateSosQuery].filter(Boolean).join(',');
|
: [stateCriticalQuery, stateSosQuery].filter(Boolean).join(',');
|
||||||
let metaFormQuery: Record<string, any> = {};
|
let metaFormQuery: Record<string, any> = {};
|
||||||
if (stateQuery?.isDisconected)
|
if (stateQuery?.isDisconnected)
|
||||||
metaFormQuery = { ...metaFormQuery, connected: false };
|
metaFormQuery = { ...metaFormQuery, connected: false };
|
||||||
const metaStateQuery =
|
const metaStateQuery =
|
||||||
stateQueryParams !== '' ? { state_level: stateQueryParams } : null;
|
stateQueryParams !== '' ? { state_level: stateQueryParams } : null;
|
||||||
@@ -252,7 +252,7 @@ const MapPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
setThings(null);
|
setThings(null);
|
||||||
const resp = await apiSearchThings(metaQuery);
|
const resp = await apiSearchThings(metaQuery, 'sgw');
|
||||||
return resp;
|
return resp;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error when searchThings: ', error);
|
console.error('Error when searchThings: ', error);
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export type TagStateCallbackPayload = {
|
|||||||
isWarning: boolean;
|
isWarning: boolean;
|
||||||
isCritical: boolean;
|
isCritical: boolean;
|
||||||
isSos: boolean;
|
isSos: boolean;
|
||||||
isDisconected: boolean; // giữ đúng theo yêu cầu (1 'n')
|
isDisconnected: boolean; // giữ đúng theo yêu cầu (1 'n')
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PointData = {
|
export type PointData = {
|
||||||
@@ -82,7 +82,7 @@ export interface GPSParseResult {
|
|||||||
|
|
||||||
export interface ShipDetailData {
|
export interface ShipDetailData {
|
||||||
ship: SgwModel.ShipDetail;
|
ship: SgwModel.ShipDetail;
|
||||||
owner: MasterModel.ProfileResponse | null;
|
owner: MasterModel.UserResponse | null;
|
||||||
gps: GPSParseResult | null;
|
gps: GPSParseResult | null;
|
||||||
trip_id?: string;
|
trip_id?: string;
|
||||||
zone_entered_alarm_list: ZoneAlarmParse[];
|
zone_entered_alarm_list: ZoneAlarmParse[];
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface EditModalProps {
|
|||||||
onVisibleChange: (visible: boolean) => void;
|
onVisibleChange: (visible: boolean) => void;
|
||||||
onFinish: (
|
onFinish: (
|
||||||
values: Pick<MasterModel.Thing, 'name'> &
|
values: Pick<MasterModel.Thing, 'name'> &
|
||||||
Pick<MasterModel.ThingMetadata, 'address' | 'external_id'>,
|
Pick<MasterModel.ThingReponseMetadata, 'address' | 'external_id'>,
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,18 @@ interface CreateTripProps {
|
|||||||
thing_id?: string;
|
thing_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TripFormValues {
|
||||||
|
name: string;
|
||||||
|
departure_time: any; // dayjs object or string
|
||||||
|
departure_port_id: number;
|
||||||
|
arrival_time?: any; // dayjs object or string
|
||||||
|
arrival_port_id?: number;
|
||||||
|
fishing_ground_codes?: number[];
|
||||||
|
fishing_gear?: SgwModel.FishingGear[];
|
||||||
|
trip_cost?: SgwModel.TripCost[];
|
||||||
|
ship_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const CreateTrip: React.FC<CreateTripProps> = ({ onSuccess, thing_id }) => {
|
const CreateTrip: React.FC<CreateTripProps> = ({ onSuccess, thing_id }) => {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
const [selectedThing, setSelectedThing] = useState<string | undefined>();
|
const [selectedThing, setSelectedThing] = useState<string | undefined>();
|
||||||
@@ -41,9 +53,9 @@ const CreateTrip: React.FC<CreateTripProps> = ({ onSuccess, thing_id }) => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [lastTrip, setLastTrip] = useState<SgwModel.Trip | null>(null);
|
const [lastTrip, setLastTrip] = useState<SgwModel.Trip | null>(null);
|
||||||
const [initialFormValues, setInitialFormValues] = useState<
|
const [initialFormValues, setInitialFormValues] = useState<
|
||||||
Partial<SgwModel.TripFormValues>
|
Partial<TripFormValues>
|
||||||
>({});
|
>({});
|
||||||
const formRef = useRef<ProFormInstance<SgwModel.TripFormValues>>(null);
|
const formRef = useRef<ProFormInstance<TripFormValues>>(null);
|
||||||
const { homeports, getHomeportsByProvinceCode } = useModel(
|
const { homeports, getHomeportsByProvinceCode } = useModel(
|
||||||
'slave.sgw.useHomePorts',
|
'slave.sgw.useHomePorts',
|
||||||
);
|
);
|
||||||
@@ -125,7 +137,7 @@ const CreateTrip: React.FC<CreateTripProps> = ({ onSuccess, thing_id }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (values: SgwModel.TripFormValues) => {
|
const handleSubmit = async (values: TripFormValues) => {
|
||||||
try {
|
try {
|
||||||
if (!selectedShip) {
|
if (!selectedShip) {
|
||||||
message.error('Vui lòng chọn tàu!');
|
message.error('Vui lòng chọn tàu!');
|
||||||
@@ -155,7 +167,7 @@ const CreateTrip: React.FC<CreateTripProps> = ({ onSuccess, thing_id }) => {
|
|||||||
.filter((n: number) => !isNaN(n))
|
.filter((n: number) => !isNaN(n))
|
||||||
: [],
|
: [],
|
||||||
fishing_gears: values.fishing_gear,
|
fishing_gears: values.fishing_gear,
|
||||||
trip_cost: values.trip_cost,
|
trip_cost: values.trip_cost || [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const thingIdToUse = selectedShip.thing_id || selectedThing;
|
const thingIdToUse = selectedShip.thing_id || selectedThing;
|
||||||
@@ -211,7 +223,7 @@ const CreateTrip: React.FC<CreateTripProps> = ({ onSuccess, thing_id }) => {
|
|||||||
>
|
>
|
||||||
<StepsForm<SgwModel.TripCreateParams>
|
<StepsForm<SgwModel.TripCreateParams>
|
||||||
stepsProps={{ size: 'small' }}
|
stepsProps={{ size: 'small' }}
|
||||||
onFinish={handleSubmit}
|
onFinish={(values) => handleSubmit(values as TripFormValues)}
|
||||||
submitter={{
|
submitter={{
|
||||||
render: (_: SubmitterProps, dom) => dom,
|
render: (_: SubmitterProps, dom) => dom,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const uploadPhoto = async (
|
|||||||
type: 'people',
|
type: 'people',
|
||||||
id: string,
|
id: string,
|
||||||
file: File,
|
file: File,
|
||||||
): Promise<void> => {
|
): Promise<{ status: number }> => {
|
||||||
return apiUploadPhoto(type, id, file);
|
return apiUploadPhoto(type, id, file);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,11 +71,11 @@ const CrewPhoto: React.FC<{ record: SgwModel.TripCrews }> = ({ record }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const photoData = await apiGetPhoto(
|
const photoResponse = await apiGetPhoto(
|
||||||
'people',
|
'people',
|
||||||
record.Person.personal_id,
|
record.Person.personal_id,
|
||||||
);
|
);
|
||||||
const blob = new Blob([photoData], { type: 'image/jpeg' });
|
const blob = new Blob([photoResponse.data], { type: 'image/jpeg' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
setPhotoSrc(url);
|
setPhotoSrc(url);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,9 +1,284 @@
|
|||||||
import React from 'react';
|
import TagState from '@/components/shared/TagState';
|
||||||
|
import {
|
||||||
|
getBadgeConnection,
|
||||||
|
getBadgeStatus,
|
||||||
|
} from '@/components/shared/ThingShared';
|
||||||
|
import TreeGroup from '@/components/shared/TreeGroup';
|
||||||
|
import { DEFAULT_PAGE_SIZE } from '@/constants';
|
||||||
|
import { apiSearchThings } from '@/services/master/ThingController';
|
||||||
|
import {
|
||||||
|
ActionType,
|
||||||
|
ProCard,
|
||||||
|
ProColumns,
|
||||||
|
ProTable,
|
||||||
|
} from '@ant-design/pro-components';
|
||||||
|
import { FormattedMessage, useIntl } from '@umijs/max';
|
||||||
|
import { Flex, Grid, theme, Typography } from 'antd';
|
||||||
|
import moment from 'moment';
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { TagStateCallbackPayload } from '../../SGW/Map/type';
|
||||||
|
const { Text } = Typography;
|
||||||
const SpoleHome: React.FC = () => {
|
const SpoleHome: React.FC = () => {
|
||||||
|
const { useBreakpoint } = Grid;
|
||||||
|
const intl = useIntl();
|
||||||
|
const screens = useBreakpoint();
|
||||||
|
const { token } = theme.useToken();
|
||||||
|
const actionRef = useRef<ActionType | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||||
|
const [groupCheckedKeys, setGroupCheckedKeys] = useState<
|
||||||
|
string | string[] | null
|
||||||
|
>(null);
|
||||||
|
const [thing, setThing] = useState<
|
||||||
|
SpoleModel.SpoleThingsResponse | undefined
|
||||||
|
>(undefined);
|
||||||
|
const [stateQuery, setStateQuery] = useState<
|
||||||
|
TagStateCallbackPayload | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
const columns: ProColumns<SpoleModel.SpoleThing>[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
ellipsis: true,
|
||||||
|
title: (
|
||||||
|
<FormattedMessage id="master.devices.name" defaultMessage="Name" />
|
||||||
|
),
|
||||||
|
tip: intl.formatMessage({
|
||||||
|
id: 'master.devices.name.tip',
|
||||||
|
defaultMessage: 'The device name',
|
||||||
|
}),
|
||||||
|
dataIndex: 'name',
|
||||||
|
hideInSearch: true,
|
||||||
|
copyable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'connected',
|
||||||
|
hideInSearch: true,
|
||||||
|
ellipsis: true,
|
||||||
|
title: (
|
||||||
|
<FormattedMessage id="common.connect" defaultMessage="Connection" />
|
||||||
|
),
|
||||||
|
dataIndex: ['metadata', 'connected'],
|
||||||
|
filters: [
|
||||||
|
{ text: 'Connected', value: true },
|
||||||
|
{ text: 'Disconnected', value: false },
|
||||||
|
],
|
||||||
|
onFilter: (value: any, row) => row?.metadata?.connected === value,
|
||||||
|
render: (_, row) => {
|
||||||
|
const connectionDuration = row.metadata!.connected
|
||||||
|
? row.metadata!.uptime! * 1000
|
||||||
|
: (Math.round(new Date().getTime() / 1000) -
|
||||||
|
row.metadata!.updated_time!) *
|
||||||
|
1000;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex gap={5}>
|
||||||
|
{getBadgeConnection(row.metadata!.connected || false)}
|
||||||
|
<Text type={row.metadata?.connected ? undefined : 'secondary'}>
|
||||||
|
{connectionDuration > 0
|
||||||
|
? moment.duration(connectionDuration).humanize()
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: intl.formatMessage({
|
||||||
|
id: 'common.status',
|
||||||
|
defaultMessage: 'Status',
|
||||||
|
}),
|
||||||
|
dataIndex: ['metadata', 'state_level'],
|
||||||
|
key: 'state_level',
|
||||||
|
hideInSearch: true,
|
||||||
|
filters: true,
|
||||||
|
onFilter: true,
|
||||||
|
valueType: 'select',
|
||||||
|
valueEnum: {
|
||||||
|
0: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'common.level.normal',
|
||||||
|
defaultMessage: 'Normal',
|
||||||
|
}),
|
||||||
|
status: 'Normal',
|
||||||
|
},
|
||||||
|
1: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'common.level.warning',
|
||||||
|
defaultMessage: 'Warning',
|
||||||
|
}),
|
||||||
|
status: 'Warning',
|
||||||
|
},
|
||||||
|
2: {
|
||||||
|
text: intl.formatMessage({
|
||||||
|
id: 'common.level.critical',
|
||||||
|
defaultMessage: 'Critical',
|
||||||
|
}),
|
||||||
|
status: 'Critical',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
render: (_, row) => {
|
||||||
|
const alarm = JSON.parse(row?.metadata?.alarm_list || '{}');
|
||||||
|
const text = alarm?.map((a: any) => a?.name).join(', ');
|
||||||
|
return (
|
||||||
|
<Flex gap={5}>
|
||||||
|
{getBadgeStatus(Number(row?.metadata?.state_level ?? -1))}
|
||||||
|
<Text type={row.metadata?.connected ? undefined : 'secondary'}>
|
||||||
|
{text}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const handleTagStateChange = (payload: TagStateCallbackPayload) => {
|
||||||
|
setStateQuery(payload);
|
||||||
|
actionRef.current?.reload();
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Trang chủ (Spole)</h1>
|
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
||||||
|
<ProCard colSpan={{ xs: 24, sm: 24, md: 4, lg: 4, xl: 4 }}>
|
||||||
|
<TreeGroup
|
||||||
|
disable={isLoading}
|
||||||
|
multiple={true}
|
||||||
|
groupIds={groupCheckedKeys}
|
||||||
|
onSelected={(value: string | string[] | null) => {
|
||||||
|
setGroupCheckedKeys(value);
|
||||||
|
if (actionRef.current) {
|
||||||
|
actionRef.current.reload();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
<ProCard colSpan={{ xs: 24, sm: 24, md: 20, lg: 20, xl: 20 }}>
|
||||||
|
<ProTable<MasterModel.Thing>
|
||||||
|
bordered={true}
|
||||||
|
columns={columns}
|
||||||
|
tableLayout="auto"
|
||||||
|
actionRef={actionRef}
|
||||||
|
rowKey="id"
|
||||||
|
search={{
|
||||||
|
layout: 'vertical', // Hoặc 'vertical' để xếp dọc
|
||||||
|
defaultCollapsed: false, // Mặc định mở rộng
|
||||||
|
span: 12, // Chiếm 12 cột trên lưới (tổng 24 cột)
|
||||||
|
filterType: 'light', // Loại filter
|
||||||
|
labelWidth: 'auto', // Độ rộng nhãn
|
||||||
|
}}
|
||||||
|
size="large"
|
||||||
|
dateFormatter="string"
|
||||||
|
pagination={{
|
||||||
|
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
|
||||||
|
showSizeChanger: true,
|
||||||
|
pageSizeOptions: ['10', '15', '20'],
|
||||||
|
showTotal: (total, range) =>
|
||||||
|
`${range[0]}-${range[1]}
|
||||||
|
${intl.formatMessage({
|
||||||
|
id: 'common.paginations.of',
|
||||||
|
defaultMessage: 'of',
|
||||||
|
})}
|
||||||
|
${total} ${intl.formatMessage({
|
||||||
|
id: 'master.devices.table.pagination',
|
||||||
|
defaultMessage: 'devices',
|
||||||
|
})}`,
|
||||||
|
}}
|
||||||
|
request={async (params = {}) => {
|
||||||
|
const {
|
||||||
|
current = 1,
|
||||||
|
pageSize,
|
||||||
|
name,
|
||||||
|
external_id,
|
||||||
|
keyword,
|
||||||
|
} = params;
|
||||||
|
const size = pageSize || DEFAULT_PAGE_SIZE * 2;
|
||||||
|
const offset = current === 1 ? 0 : (current - 1) * size;
|
||||||
|
setIsLoading(true);
|
||||||
|
const stateNormalQuery = stateQuery?.isNormal ? 'normal' : '';
|
||||||
|
const stateSosQuery = stateQuery?.isSos ? 'sos' : '';
|
||||||
|
const stateWarningQuery = stateQuery?.isWarning
|
||||||
|
? stateNormalQuery + ',warning'
|
||||||
|
: stateNormalQuery;
|
||||||
|
const stateCriticalQuery = stateQuery?.isCritical
|
||||||
|
? stateWarningQuery + ',critical'
|
||||||
|
: stateWarningQuery;
|
||||||
|
const stateQueryParams =
|
||||||
|
stateQuery?.isNormal &&
|
||||||
|
stateQuery?.isWarning &&
|
||||||
|
stateQuery?.isCritical &&
|
||||||
|
stateQuery?.isSos
|
||||||
|
? ''
|
||||||
|
: [stateCriticalQuery, stateSosQuery]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(',');
|
||||||
|
let metaFormQuery: Record<string, any> = {};
|
||||||
|
if (stateQuery?.isDisconnected)
|
||||||
|
metaFormQuery = { ...metaFormQuery, connected: false };
|
||||||
|
const metaStateQuery =
|
||||||
|
stateQueryParams !== ''
|
||||||
|
? { state_level: stateQueryParams }
|
||||||
|
: null;
|
||||||
|
let metadata: Partial<MasterModel.SearchThingMetadata> = {};
|
||||||
|
if (external_id) metadata.external_id = external_id;
|
||||||
|
|
||||||
|
if (metaStateQuery) metadata = { ...metadata, ...metaStateQuery };
|
||||||
|
// Add group filter if groups are selected
|
||||||
|
if (groupCheckedKeys && groupCheckedKeys.length > 0) {
|
||||||
|
const groupId = Array.isArray(groupCheckedKeys)
|
||||||
|
? groupCheckedKeys.join(',')
|
||||||
|
: groupCheckedKeys;
|
||||||
|
metadata.group_id = groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const query: MasterModel.SearchThingPaginationBody = {
|
||||||
|
offset: offset,
|
||||||
|
limit: size,
|
||||||
|
order: 'name',
|
||||||
|
dir: 'asc',
|
||||||
|
};
|
||||||
|
if (keyword) query.name = keyword;
|
||||||
|
if (Object.keys(metadata).length > 0) query.metadata = metadata;
|
||||||
|
try {
|
||||||
|
const response = await apiSearchThings(query, 'spole');
|
||||||
|
setIsLoading(false);
|
||||||
|
setThing(response);
|
||||||
|
return {
|
||||||
|
data: response.things || [],
|
||||||
|
success: true,
|
||||||
|
total: response.total || 0,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
setIsLoading(false);
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
success: false,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
options={{
|
||||||
|
search: true,
|
||||||
|
setting: false,
|
||||||
|
density: false,
|
||||||
|
reload: true,
|
||||||
|
}}
|
||||||
|
toolbar={{
|
||||||
|
actions: [
|
||||||
|
<TagState
|
||||||
|
key={'device-state-tag'}
|
||||||
|
normalCount={thing?.metadata?.total_state_level_0 || 0}
|
||||||
|
warningCount={thing?.metadata?.total_state_level_1 || 0}
|
||||||
|
criticalCount={thing?.metadata?.total_state_level_2 || 0}
|
||||||
|
sosCount={undefined}
|
||||||
|
disconnectedCount={
|
||||||
|
(thing?.metadata?.total_thing ?? 0) -
|
||||||
|
(thing?.metadata?.total_connected ?? 0) || 0
|
||||||
|
}
|
||||||
|
onTagPress={handleTagStateChange}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ProCard>
|
||||||
|
</ProCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ export async function apiLogin(body: MasterModel.LoginRequestBody) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function apiQueryProfile() {
|
export async function apiQueryProfile() {
|
||||||
return request<MasterModel.ProfileResponse>(API_PATH_GET_PROFILE);
|
return request<MasterModel.UserResponse>(API_PATH_GET_PROFILE);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiUpdateProfile(
|
export async function apiUpdateProfile(
|
||||||
body: Partial<MasterModel.ProfileMetadata>,
|
body: Partial<MasterModel.UserMetadata>,
|
||||||
) {
|
) {
|
||||||
return request<MasterModel.ProfileResponse>(API_USERS, {
|
return request<MasterModel.UserResponse>(API_USERS, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
data: {
|
data: {
|
||||||
metadata: body,
|
metadata: body,
|
||||||
|
|||||||
@@ -5,6 +5,26 @@ import {
|
|||||||
} from '@/constants/api';
|
} from '@/constants/api';
|
||||||
import { request } from '@umijs/max';
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
|
export async function apiSearchThings(
|
||||||
|
body: MasterModel.SearchPaginationBody,
|
||||||
|
domain: 'spole',
|
||||||
|
): Promise<SpoleModel.SpoleThingsResponse>;
|
||||||
|
export async function apiSearchThings(
|
||||||
|
body: MasterModel.SearchPaginationBody,
|
||||||
|
domain: 'sgw',
|
||||||
|
): Promise<SgwModel.SgwThingsResponse>;
|
||||||
|
export async function apiSearchThings(
|
||||||
|
body: MasterModel.SearchPaginationBody,
|
||||||
|
domain?: 'gms',
|
||||||
|
): Promise<GmsModel.GmsThingsResponse>;
|
||||||
|
export async function apiSearchThings(
|
||||||
|
body: MasterModel.SearchPaginationBody,
|
||||||
|
domain?: string,
|
||||||
|
): Promise<
|
||||||
|
| SpoleModel.SpoleThingsResponse
|
||||||
|
| SgwModel.SgwThingsResponse
|
||||||
|
| GmsModel.GmsThingsResponse
|
||||||
|
>;
|
||||||
export async function apiSearchThings(
|
export async function apiSearchThings(
|
||||||
body: MasterModel.SearchPaginationBody,
|
body: MasterModel.SearchPaginationBody,
|
||||||
domain: string = process.env.DOMAIN_ENV || 'gms',
|
domain: string = process.env.DOMAIN_ENV || 'gms',
|
||||||
@@ -33,6 +53,14 @@ export async function apiSearchThings(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function apiUpdateThing(value: MasterModel.Thing) {
|
||||||
|
if (!value.id) throw new Error('Thing id is required');
|
||||||
|
return request<MasterModel.Thing>(`${API_SHARE_THING}/${value.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export async function apiGetThingPolicyByUser(
|
export async function apiGetThingPolicyByUser(
|
||||||
params: Partial<MasterModel.SearchPaginationBody>,
|
params: Partial<MasterModel.SearchPaginationBody>,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -4,19 +4,21 @@ import { request } from '@umijs/max';
|
|||||||
export async function apiQueryUsers(
|
export async function apiQueryUsers(
|
||||||
params: MasterModel.SearchUserPaginationBody,
|
params: MasterModel.SearchUserPaginationBody,
|
||||||
) {
|
) {
|
||||||
return request<MasterModel.UserResponse>(API_USERS, {
|
return request<MasterModel.UserListResponse>(API_USERS, {
|
||||||
params: params,
|
params: params,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiQueryUserById(userId: string) {
|
export async function apiQueryUserById(userId: string) {
|
||||||
return request<MasterModel.ProfileResponse>(`${API_USERS}/${userId}`);
|
return request<MasterModel.UserResponse>(`${API_USERS}/${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiQueryUsersByGroup(
|
export async function apiQueryUsersByGroup(
|
||||||
group_id: string,
|
group_id: string,
|
||||||
): Promise<MasterModel.UserResponse> {
|
): Promise<MasterModel.UserListResponse> {
|
||||||
return request<MasterModel.UserResponse>(`${API_USERS_BY_GROUP}/${group_id}`);
|
return request<MasterModel.UserListResponse>(
|
||||||
|
`${API_USERS_BY_GROUP}/${group_id}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiCreateUsers(body: MasterModel.CreateUserBodyRequest) {
|
export async function apiCreateUsers(body: MasterModel.CreateUserBodyRequest) {
|
||||||
|
|||||||
245
src/services/master/typings.d.ts
vendored
245
src/services/master/typings.d.ts
vendored
@@ -8,254 +8,13 @@ declare namespace MasterModel {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
dir?: 'asc' | 'desc';
|
dir?: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
interface SearchThingPaginationBody extends SearchPaginationBody {
|
|
||||||
order?: string;
|
|
||||||
metadata?: ThingMetadata;
|
|
||||||
}
|
|
||||||
interface ThingMetadata {
|
|
||||||
group_id?: string;
|
|
||||||
external_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchAlarmPaginationBody extends SearchPaginationBody {
|
interface PaginationReponse {
|
||||||
order?: 'name' | undefined;
|
|
||||||
thing_name?: string;
|
|
||||||
thing_id?: string;
|
|
||||||
level?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SearchUserPaginationBody extends SearchPaginationBody {
|
|
||||||
order?: 'email' | 'name' | undefined;
|
|
||||||
email?: string;
|
|
||||||
metadata?: Partial<ProfileMetadata>;
|
|
||||||
}
|
|
||||||
interface SearchLogPaginationBody extends SearchPaginationBody {
|
|
||||||
from?: number;
|
|
||||||
to?: number;
|
|
||||||
publisher?: string;
|
|
||||||
subtopic?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auth
|
|
||||||
interface LoginRequestBody {
|
|
||||||
guid: string;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LoginResponse {
|
|
||||||
token?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChangePasswordRequestBody {
|
|
||||||
old_password: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProfileResponse {
|
|
||||||
id?: string;
|
|
||||||
email?: string;
|
|
||||||
metadata?: ProfileMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProfileMetadata {
|
|
||||||
frontend_thing_id?: string;
|
|
||||||
frontend_thing_key?: string;
|
|
||||||
full_name?: string;
|
|
||||||
phone_number?: string;
|
|
||||||
telegram?: string;
|
|
||||||
user_type?: 'admin' | 'enduser' | 'sysadmin' | 'users';
|
|
||||||
}
|
|
||||||
|
|
||||||
// User
|
|
||||||
interface CreateUserMetadata extends ProfileMetadata {
|
|
||||||
group_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CreateUserBodyRequest extends Partial<ProfileResponse> {
|
|
||||||
password: string;
|
|
||||||
full_name?: string;
|
|
||||||
metadata?: CreateUserMetadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UserResponse {
|
|
||||||
total?: number;
|
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
|
||||||
users: ProfileResponse[];
|
|
||||||
}
|
|
||||||
interface AlarmsResponse {
|
|
||||||
total?: number;
|
|
||||||
limit?: number;
|
|
||||||
order?: string;
|
|
||||||
dir?: string;
|
|
||||||
alarms?: Alarm[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConfirmAlarmRequest {
|
|
||||||
id?: string;
|
|
||||||
description?: string;
|
|
||||||
thing_id?: string;
|
|
||||||
time?: number;
|
|
||||||
}
|
|
||||||
// Alarm
|
|
||||||
interface Alarm {
|
|
||||||
name?: string;
|
|
||||||
time?: number;
|
|
||||||
level?: number;
|
|
||||||
id?: string;
|
|
||||||
confirmed?: boolean;
|
|
||||||
confirmed_email?: string;
|
|
||||||
confirmed_time?: number;
|
|
||||||
confirmed_desc?: string;
|
|
||||||
thing_id?: string;
|
|
||||||
thing_name?: string;
|
|
||||||
thing_type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thing
|
|
||||||
interface ThingMetadata {
|
|
||||||
address?: string;
|
|
||||||
alarm_list?: string;
|
|
||||||
cfg_channel_id?: string;
|
|
||||||
connected?: boolean;
|
|
||||||
ctrl_channel_id?: string;
|
|
||||||
data_channel_id?: string;
|
|
||||||
enduser?: string;
|
|
||||||
external_id?: string;
|
|
||||||
group_id?: string;
|
|
||||||
req_channel_id?: string;
|
|
||||||
state?: string;
|
|
||||||
state_level?: number;
|
|
||||||
state_updated_time?: number;
|
|
||||||
type?: string;
|
|
||||||
updated_time?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThingsResponse<T extends ThingMetadata = ThingMetadata> {
|
|
||||||
total?: number;
|
total?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
order?: string;
|
order?: string;
|
||||||
direction?: string;
|
direction?: string;
|
||||||
metadata?: ThingsResponseMetadata;
|
|
||||||
things?: Thing<T>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThingsResponseMetadata {
|
|
||||||
total_connected?: number;
|
|
||||||
total_filter?: number;
|
|
||||||
total_sos?: number;
|
|
||||||
total_state_level_0?: number;
|
|
||||||
total_state_level_1?: number;
|
|
||||||
total_state_level_2?: number;
|
|
||||||
total_thing?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Thing<T extends ThingMetadata = ThingMetadata> {
|
|
||||||
id?: string;
|
|
||||||
name?: string;
|
|
||||||
key?: string;
|
|
||||||
metadata?: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Thing Policy
|
|
||||||
interface ThingPolicyResponse {
|
|
||||||
total?: number;
|
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
|
||||||
order?: string;
|
|
||||||
direction?: string;
|
|
||||||
metadata?: null;
|
|
||||||
things?: ThingPolicy[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ThingPolicy {
|
|
||||||
policies?: Policy[];
|
|
||||||
thing_id?: string;
|
|
||||||
thing_name?: string;
|
|
||||||
external_id?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Policy = 'read' | 'delete' | 'write';
|
|
||||||
|
|
||||||
// Group
|
|
||||||
|
|
||||||
interface GroupBodyRequest {
|
|
||||||
id?: string;
|
|
||||||
name: string;
|
|
||||||
parent_id?: string;
|
|
||||||
description?: string;
|
|
||||||
metadata?: GroupMetadata;
|
|
||||||
}
|
|
||||||
interface AddGroupBodyResponse extends Partial<GroupBodyRequest> {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupMetadata {
|
|
||||||
code?: string;
|
|
||||||
short_name?: string;
|
|
||||||
has_thing?: boolean;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupResponse {
|
|
||||||
total?: number;
|
|
||||||
level?: number;
|
|
||||||
name?: string;
|
|
||||||
groups?: GroupNode[];
|
|
||||||
}
|
|
||||||
interface GroupNode {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
owner_id: string;
|
|
||||||
description: string;
|
|
||||||
metadata: GroupMetadata;
|
|
||||||
level: number;
|
|
||||||
path: string;
|
|
||||||
parent_id?: string;
|
|
||||||
created_at?: string;
|
|
||||||
updated_at?: string;
|
|
||||||
children?: GroupNode[]; // Đệ quy: mỗi node có thể có children là mảng GroupNode
|
|
||||||
[key: string]: any; // Nếu có thêm trường động
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupQueryParams {
|
|
||||||
level?: number;
|
|
||||||
tree?: boolean;
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log
|
|
||||||
|
|
||||||
type LogTypeRequest = 'user_logs' | undefined;
|
|
||||||
|
|
||||||
interface LogResponse {
|
|
||||||
offset?: number;
|
|
||||||
limit?: number;
|
|
||||||
publisher?: string;
|
|
||||||
from?: number;
|
|
||||||
to?: number;
|
|
||||||
format?: string;
|
|
||||||
total?: number;
|
|
||||||
messages?: Message[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Message {
|
|
||||||
channel?: string;
|
|
||||||
subtopic?: string;
|
|
||||||
publisher?: string;
|
|
||||||
protocol?: string;
|
|
||||||
name?: string;
|
|
||||||
time?: number;
|
|
||||||
string_value?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// User
|
|
||||||
|
|
||||||
interface AssignMemberRequest {
|
|
||||||
group_id: string;
|
|
||||||
type: 'users' | 'admin' | 'things' | undefined;
|
|
||||||
members: string[];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
7;
|
||||||
|
|||||||
36
src/services/master/typings/alarm.d.ts
vendored
Normal file
36
src/services/master/typings/alarm.d.ts
vendored
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
declare namespace MasterModel {
|
||||||
|
interface SearchAlarmPaginationBody extends SearchPaginationBody {
|
||||||
|
order?: 'name' | undefined;
|
||||||
|
thing_name?: string;
|
||||||
|
thing_id?: string;
|
||||||
|
level?: number;
|
||||||
|
}
|
||||||
|
interface AlarmsResponse {
|
||||||
|
total?: number;
|
||||||
|
limit?: number;
|
||||||
|
order?: string;
|
||||||
|
dir?: string;
|
||||||
|
alarms?: Alarm[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmAlarmRequest {
|
||||||
|
id?: string;
|
||||||
|
description?: string;
|
||||||
|
thing_id?: string;
|
||||||
|
time?: number;
|
||||||
|
}
|
||||||
|
// Alarm
|
||||||
|
interface Alarm {
|
||||||
|
name?: string;
|
||||||
|
time?: number;
|
||||||
|
level?: number;
|
||||||
|
id?: string;
|
||||||
|
confirmed?: boolean;
|
||||||
|
confirmed_email?: string;
|
||||||
|
confirmed_time?: number;
|
||||||
|
confirmed_desc?: string;
|
||||||
|
thing_id?: string;
|
||||||
|
thing_name?: string;
|
||||||
|
thing_type?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/services/master/typings/auth.d.ts
vendored
Normal file
11
src/services/master/typings/auth.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
declare namespace MasterModel {
|
||||||
|
interface LoginRequestBody {
|
||||||
|
guid: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoginResponse {
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/services/master/typings/group.d.ts
vendored
Normal file
52
src/services/master/typings/group.d.ts
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
declare namespace MasterModel {
|
||||||
|
interface GroupBodyRequest {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
parent_id?: string;
|
||||||
|
description?: string;
|
||||||
|
metadata?: GroupMetadata;
|
||||||
|
}
|
||||||
|
interface AddGroupBodyResponse extends Partial<GroupBodyRequest> {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupMetadata {
|
||||||
|
code?: string;
|
||||||
|
short_name?: string;
|
||||||
|
has_thing?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupResponse {
|
||||||
|
total?: number;
|
||||||
|
level?: number;
|
||||||
|
name?: string;
|
||||||
|
groups?: GroupNode[];
|
||||||
|
}
|
||||||
|
interface GroupNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
owner_id: string;
|
||||||
|
description: string;
|
||||||
|
metadata: GroupMetadata;
|
||||||
|
level: number;
|
||||||
|
path: string;
|
||||||
|
parent_id?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
children?: GroupNode[]; // Đệ quy: mỗi node có thể có children là mảng GroupNode
|
||||||
|
[key: string]: any; // Nếu có thêm trường động
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupQueryParams {
|
||||||
|
level?: number;
|
||||||
|
tree?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AssignMemberRequest {
|
||||||
|
group_id: string;
|
||||||
|
type: 'users' | 'admin' | 'things' | undefined;
|
||||||
|
members: string[];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/services/master/typings/log.d.ts
vendored
Normal file
31
src/services/master/typings/log.d.ts
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
declare namespace MasterModel {
|
||||||
|
interface SearchLogPaginationBody extends SearchPaginationBody {
|
||||||
|
from?: number;
|
||||||
|
to?: number;
|
||||||
|
publisher?: string;
|
||||||
|
subtopic?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogTypeRequest = 'user_logs' | undefined;
|
||||||
|
|
||||||
|
interface LogResponse {
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
publisher?: string;
|
||||||
|
from?: number;
|
||||||
|
to?: number;
|
||||||
|
format?: string;
|
||||||
|
total?: number;
|
||||||
|
messages?: Message[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
channel?: string;
|
||||||
|
subtopic?: string;
|
||||||
|
publisher?: string;
|
||||||
|
protocol?: string;
|
||||||
|
name?: string;
|
||||||
|
time?: number;
|
||||||
|
string_value?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/services/master/typings/thing.d.ts
vendored
Normal file
75
src/services/master/typings/thing.d.ts
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
declare namespace MasterModel {
|
||||||
|
interface SearchThingPaginationBody<T = SearchThingMetadata>
|
||||||
|
extends SearchPaginationBody {
|
||||||
|
order?: string;
|
||||||
|
metadata?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchThingMetadata {
|
||||||
|
external_id?: string;
|
||||||
|
state_level?: string; // vd: "normal,warning,critical,sos"
|
||||||
|
/** kết nối */
|
||||||
|
connected?: boolean;
|
||||||
|
group_id?: string; // "id1,id2,id3"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thing
|
||||||
|
interface ThingReponseMetadata {
|
||||||
|
address?: string;
|
||||||
|
alarm_list?: string;
|
||||||
|
cfg_channel_id?: string;
|
||||||
|
connected?: boolean;
|
||||||
|
ctrl_channel_id?: string;
|
||||||
|
data_channel_id?: string;
|
||||||
|
enduser?: string;
|
||||||
|
external_id?: string;
|
||||||
|
group_id?: string;
|
||||||
|
req_channel_id?: string;
|
||||||
|
state?: string;
|
||||||
|
state_level?: number;
|
||||||
|
state_updated_time?: number;
|
||||||
|
type?: string;
|
||||||
|
updated_time?: number;
|
||||||
|
lat?: string;
|
||||||
|
lng?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThingsResponse<
|
||||||
|
T extends ThingReponseMetadata = ThingReponseMetadata,
|
||||||
|
> extends PaginationReponse {
|
||||||
|
metadata?: ThingsResponseMetadata;
|
||||||
|
things?: Thing<T>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThingsResponseMetadata {
|
||||||
|
total_connected?: number;
|
||||||
|
total_filter?: number;
|
||||||
|
total_sos?: number;
|
||||||
|
total_state_level_0?: number;
|
||||||
|
total_state_level_1?: number;
|
||||||
|
total_state_level_2?: number;
|
||||||
|
total_thing?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Thing<T extends ThingReponseMetadata = ThingReponseMetadata> {
|
||||||
|
id?: string;
|
||||||
|
name?: string;
|
||||||
|
key?: string;
|
||||||
|
metadata?: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Thing Policy
|
||||||
|
interface ThingPolicyResponse extends PaginationReponse {
|
||||||
|
metadata?: null;
|
||||||
|
things?: ThingPolicy[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ThingPolicy {
|
||||||
|
policies?: Policy[];
|
||||||
|
thing_id?: string;
|
||||||
|
thing_name?: string;
|
||||||
|
external_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Policy = 'read' | 'delete' | 'write';
|
||||||
|
}
|
||||||
44
src/services/master/typings/user.d.ts
vendored
Normal file
44
src/services/master/typings/user.d.ts
vendored
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
declare namespace MasterModel {
|
||||||
|
interface SearchUserPaginationBody extends SearchPaginationBody {
|
||||||
|
order?: 'email' | 'name' | undefined;
|
||||||
|
email?: string;
|
||||||
|
metadata?: Partial<UserMetadata>;
|
||||||
|
}
|
||||||
|
interface ChangePasswordRequestBody {
|
||||||
|
old_password: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserResponse {
|
||||||
|
id?: string;
|
||||||
|
email?: string;
|
||||||
|
metadata?: UserMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserMetadata {
|
||||||
|
frontend_thing_id?: string;
|
||||||
|
frontend_thing_key?: string;
|
||||||
|
full_name?: string;
|
||||||
|
phone_number?: string;
|
||||||
|
telegram?: string;
|
||||||
|
user_type?: 'admin' | 'enduser' | 'sysadmin' | 'users';
|
||||||
|
}
|
||||||
|
|
||||||
|
// User
|
||||||
|
interface CreateUserMetadata extends UserMetadata {
|
||||||
|
group_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateUserBodyRequest extends Partial<UserResponse> {
|
||||||
|
password: string;
|
||||||
|
full_name?: string;
|
||||||
|
metadata?: CreateUserMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserListResponse {
|
||||||
|
total?: number;
|
||||||
|
offset?: number;
|
||||||
|
limit?: number;
|
||||||
|
users: UserResponse[];
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/services/slave/sgw/FishController.ts
Normal file
36
src/services/slave/sgw/FishController.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import {
|
||||||
|
SGW_ROUTE_CREATE_OR_UPDATE_FISH,
|
||||||
|
SGW_ROUTE_GET_FISH,
|
||||||
|
} from '@/constants/slave/sgw/routes';
|
||||||
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
|
export async function apiGetFishSpecies(
|
||||||
|
body?: SgwModel.SearchFishPaginationBody,
|
||||||
|
): Promise<SgwModel.FishSpeciesResponse> {
|
||||||
|
return request<SgwModel.FishSpeciesResponse>(SGW_ROUTE_GET_FISH, {
|
||||||
|
method: 'POST',
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiCreateFishSpecies(body?: SgwModel.Fish) {
|
||||||
|
return request<SgwModel.CreateFishResponse>(SGW_ROUTE_CREATE_OR_UPDATE_FISH, {
|
||||||
|
method: 'POST',
|
||||||
|
data: [body],
|
||||||
|
getResponse: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiUpdateFishSpecies(body?: SgwModel.Fish) {
|
||||||
|
return request(SGW_ROUTE_CREATE_OR_UPDATE_FISH, {
|
||||||
|
method: 'PUT',
|
||||||
|
data: body,
|
||||||
|
getResponse: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function apiDeleteFishSpecies(id?: string) {
|
||||||
|
return request(`${SGW_ROUTE_CREATE_OR_UPDATE_FISH}/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
getResponse: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,38 +1,80 @@
|
|||||||
import { SGW_ROUTE_PHOTO } from '@/constants/slave/sgw/routes';
|
import {
|
||||||
|
SGW_ROUTE_PHOTO,
|
||||||
|
SGW_ROUTE_PHOTO_TAGS,
|
||||||
|
} from '@/constants/slave/sgw/routes';
|
||||||
import { request } from '@umijs/max';
|
import { request } from '@umijs/max';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get photo from server
|
* Get photo from server
|
||||||
* @param type Type of photo ('ship' or 'people')
|
* @param type Type of photo ('ship' or 'people' or 'fish')
|
||||||
* @param id ID of the entity
|
* @param id ID of the entity
|
||||||
* @returns Photo as ArrayBuffer
|
* @param tag Photo tag (default: 'main')
|
||||||
|
* @returns Photo response with ArrayBuffer data
|
||||||
*/
|
*/
|
||||||
export async function apiGetPhoto(
|
export async function apiGetPhoto(
|
||||||
type: SgwModel.PhotoGetParams['type'],
|
type: SgwModel.PhotoGetParams['type'],
|
||||||
id: string,
|
id: string | number,
|
||||||
): Promise<ArrayBuffer> {
|
tag: string = 'main',
|
||||||
return request<ArrayBuffer>(`${SGW_ROUTE_PHOTO}/${type}/${id}/main`, {
|
): Promise<{ status: number; data: ArrayBuffer }> {
|
||||||
|
const response = await request<ArrayBuffer>(
|
||||||
|
`${SGW_ROUTE_PHOTO}/${type}/${id}/${tag}`,
|
||||||
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
responseType: 'arraybuffer',
|
responseType: 'arraybuffer',
|
||||||
});
|
getResponse: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 200,
|
||||||
|
data: response.data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiGetTagsPhoto(
|
||||||
|
type: SgwModel.PhotoGetParams['type'],
|
||||||
|
id: string | number,
|
||||||
|
) {
|
||||||
|
return request<SgwModel.GetTagsResponse>(
|
||||||
|
`${SGW_ROUTE_PHOTO_TAGS}/${type}/${id}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload photo to server
|
* Upload photo to server
|
||||||
* @param type Type of photo ('ship' or 'people')
|
* @param type Type of photo ('ship' or 'people' or 'fish')
|
||||||
* @param id ID of the entity
|
* @param id ID of the entity
|
||||||
* @param file File to upload
|
* @param file File to upload
|
||||||
|
* @param tag Photo tag (default: 'main')
|
||||||
*/
|
*/
|
||||||
export async function apiUploadPhoto(
|
export async function apiUploadPhoto(
|
||||||
type: SgwModel.PhotoUploadParams['type'],
|
type: SgwModel.PhotoUploadParams['type'],
|
||||||
id: string,
|
id: string,
|
||||||
file: File,
|
file: File,
|
||||||
): Promise<void> {
|
tag: string = 'main',
|
||||||
|
): Promise<{ status: number }> {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
formData.append('file', file);
|
||||||
|
|
||||||
return request<void>(`${SGW_ROUTE_PHOTO}/${type}/${id}/main`, {
|
await request<void>(`${SGW_ROUTE_PHOTO}/${type}/${id}/${tag}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: formData,
|
data: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { status: 200 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete photo from server
|
||||||
|
*/
|
||||||
|
export async function apiDeletePhoto(
|
||||||
|
type: SgwModel.PhotoGetParams['type'],
|
||||||
|
id: string | number,
|
||||||
|
tag: string = 'main',
|
||||||
|
): Promise<{ status: number }> {
|
||||||
|
await request(`${SGW_ROUTE_PHOTO}/${type}/${id}/${tag}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: 200 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { request } from '@umijs/max';
|
|||||||
* @param body Search and pagination parameters
|
* @param body Search and pagination parameters
|
||||||
*/
|
*/
|
||||||
export async function apiGetAllBanzones(
|
export async function apiGetAllBanzones(
|
||||||
body: MasterModel.SearchPaginationBody,
|
body: SgwModel.SearchZonePaginationBody,
|
||||||
) {
|
) {
|
||||||
return request<SgwModel.ZoneResponse>(SGW_ROUTE_BANZONES_LIST, {
|
return request<SgwModel.ZoneResponse>(SGW_ROUTE_BANZONES_LIST, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -42,7 +42,7 @@ export async function apiGetZoneById(
|
|||||||
* Create a new banzone
|
* Create a new banzone
|
||||||
* @param body Banzone data
|
* @param body Banzone data
|
||||||
*/
|
*/
|
||||||
export async function apiCreateBanzone(body: SgwModel.ZoneBodyRequest) {
|
export async function apiCreateBanzone(body: SgwModel.ZoneBasicInfo) {
|
||||||
return request(SGW_ROUTE_BANZONES, {
|
return request(SGW_ROUTE_BANZONES, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: body,
|
data: body,
|
||||||
@@ -56,7 +56,7 @@ export async function apiCreateBanzone(body: SgwModel.ZoneBodyRequest) {
|
|||||||
*/
|
*/
|
||||||
export async function apiUpdateBanzone(
|
export async function apiUpdateBanzone(
|
||||||
id: string,
|
id: string,
|
||||||
body: SgwModel.ZoneBodyRequest,
|
body: SgwModel.ZoneBasicInfo,
|
||||||
) {
|
) {
|
||||||
return request(`${SGW_ROUTE_BANZONES}/${id}`, {
|
return request(`${SGW_ROUTE_BANZONES}/${id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
440
src/services/slave/sgw/sgw.typing.d.ts
vendored
440
src/services/slave/sgw/sgw.typing.d.ts
vendored
@@ -3,7 +3,7 @@
|
|||||||
|
|
||||||
declare namespace SgwModel {
|
declare namespace SgwModel {
|
||||||
// Thing
|
// Thing
|
||||||
interface ThingMedata extends MasterModel.ThingMetadata {
|
interface ThingMedata extends MasterModel.ThingReponseMetadata {
|
||||||
gps?: string;
|
gps?: string;
|
||||||
gps_time?: string;
|
gps_time?: string;
|
||||||
ship_group_id?: string;
|
ship_group_id?: string;
|
||||||
@@ -23,442 +23,4 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
17
src/services/slave/sgw/typings/crew.d.ts
vendored
Normal file
17
src/services/slave/sgw/typings/crew.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
interface CrewBaseInfo {
|
||||||
|
name: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
birth_date?: string;
|
||||||
|
address?: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
interface CrewCreateParams extends CrewBaseInfo {
|
||||||
|
personal_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CrewUpdateParams extends Partial<CrewBaseInfo> {
|
||||||
|
personal_id?: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/services/slave/sgw/typings/fish.d.ts
vendored
Normal file
54
src/services/slave/sgw/typings/fish.d.ts
vendored
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
interface FishSpeciesResponse {
|
||||||
|
fishes: Fish[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FishCreateRequest {
|
||||||
|
name: string;
|
||||||
|
scientific_name?: string;
|
||||||
|
group_name?: string;
|
||||||
|
rarity_level?: number;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FishUpdateRequest extends FishCreateRequest {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Fish {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
scientific_name?: string;
|
||||||
|
group_name?: string;
|
||||||
|
species_code?: string;
|
||||||
|
note?: string;
|
||||||
|
default_unit?: string;
|
||||||
|
rarity_level?: number;
|
||||||
|
created_at?: Date;
|
||||||
|
updated_at?: Date;
|
||||||
|
is_deleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FishRarity {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
iucn_code: string | null;
|
||||||
|
cites_appendix: string | null;
|
||||||
|
vn_law: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateFishResponse {
|
||||||
|
name_ids?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchFishPaginationBody extends MasterModel.SearchPaginationBody {
|
||||||
|
order?: string;
|
||||||
|
metadata?: {
|
||||||
|
group_name?: string;
|
||||||
|
rarity_level?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
35
src/services/slave/sgw/typings/fishing_log.d.ts
vendored
Normal file
35
src/services/slave/sgw/typings/fishing_log.d.ts
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
interface FishingLogInfo {
|
||||||
|
fish_species_id?: number;
|
||||||
|
fish_name?: string;
|
||||||
|
catch_number?: number;
|
||||||
|
catch_unit?: string;
|
||||||
|
fish_size?: number;
|
||||||
|
fish_rarity?: number;
|
||||||
|
fish_condition?: string;
|
||||||
|
gear_usage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FishingLog {
|
||||||
|
fishing_log_id?: string;
|
||||||
|
trip_id: string;
|
||||||
|
start_at: Date;
|
||||||
|
end_at: Date;
|
||||||
|
start_lat: number;
|
||||||
|
start_lon: number;
|
||||||
|
haul_lat: number;
|
||||||
|
haul_lon: number;
|
||||||
|
status: number;
|
||||||
|
weather_description: string;
|
||||||
|
info?: FishingLogInfo[];
|
||||||
|
sync: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewFishingLogRequest {
|
||||||
|
trip_id: string;
|
||||||
|
start_at: Date;
|
||||||
|
start_lat: number;
|
||||||
|
start_lon: number;
|
||||||
|
weather_description: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/services/slave/sgw/typings/gear.d.ts
vendored
Normal file
6
src/services/slave/sgw/typings/gear.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
interface FishingGear {
|
||||||
|
name: string;
|
||||||
|
number: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/services/slave/sgw/typings/photo.d.ts
vendored
Normal file
23
src/services/slave/sgw/typings/photo.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
// interface PhotoBasicInfo {
|
||||||
|
// type: 'ship' | 'people';
|
||||||
|
// id: string;
|
||||||
|
// }
|
||||||
|
|
||||||
|
interface PhotoGetParams {
|
||||||
|
type: 'ship' | 'people' | 'fish';
|
||||||
|
id: string;
|
||||||
|
tag: 'main' | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhotoUploadParams {
|
||||||
|
type: 'ship' | 'people' | 'fish';
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
tag: 'main' | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetTagsResponse {
|
||||||
|
tags?: string[];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/services/slave/sgw/typings/port.d.ts
vendored
Normal file
24
src/services/slave/sgw/typings/port.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
interface Port {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
classification: string;
|
||||||
|
position_point: string;
|
||||||
|
has_origin_confirm: boolean;
|
||||||
|
province_code: string;
|
||||||
|
updated_at: string;
|
||||||
|
is_deleted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PortQueryParams extends MasterModel.SearchPaginationBody {
|
||||||
|
order?: string;
|
||||||
|
metadata?: {
|
||||||
|
province_code?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PortQueryResponse extends MasterModel.PaginationReponse {
|
||||||
|
ports: Port[];
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/services/slave/sgw/typings/ship.d.ts
vendored
Normal file
89
src/services/slave/sgw/typings/ship.d.ts
vendored
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
interface ShipBaseInfo {
|
||||||
|
name?: string;
|
||||||
|
reg_number?: string;
|
||||||
|
ship_type?: number;
|
||||||
|
ship_length?: number;
|
||||||
|
ship_power?: number;
|
||||||
|
home_port?: number;
|
||||||
|
fishing_license_number?: string;
|
||||||
|
fishing_license_expiry_date?: string;
|
||||||
|
ship_group_id?: string | null; // Lưu ý: Update có thể cần null để gỡ nhóm
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShipType {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
interface ShipMetadata {
|
||||||
|
crew_count?: number;
|
||||||
|
home_port?: string;
|
||||||
|
home_port_point?: string;
|
||||||
|
ship_type?: string;
|
||||||
|
trip_arrival_port?: string;
|
||||||
|
trip_arrival_port_point?: string;
|
||||||
|
trip_arrival_time?: Date;
|
||||||
|
trip_depart_port?: string;
|
||||||
|
trip_depart_port_point?: string;
|
||||||
|
trip_departure_time?: Date;
|
||||||
|
trip_id?: string;
|
||||||
|
trip_name?: string;
|
||||||
|
trip_state?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShipDetail extends ShipBaseInfo {
|
||||||
|
id?: string;
|
||||||
|
thing_id?: string;
|
||||||
|
owner_id?: string;
|
||||||
|
imo_number?: string;
|
||||||
|
mmsi_number?: string;
|
||||||
|
province_code?: string;
|
||||||
|
created_at?: Date | string;
|
||||||
|
updated_at?: Date | string;
|
||||||
|
metadata?: ShipMetadata;
|
||||||
|
}
|
||||||
|
interface ShipCreateParams extends ShipBaseInfo {
|
||||||
|
thing_id?: string;
|
||||||
|
}
|
||||||
|
interface ShipUpdateParams {
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
interface ShipQueryParams extends MasterModel.SearchPaginationBody {
|
||||||
|
order?: string;
|
||||||
|
registration_number?: string;
|
||||||
|
ship_type?: number;
|
||||||
|
ship_group_id?: string;
|
||||||
|
thing_id?: string;
|
||||||
|
}
|
||||||
|
interface ShipQueryResponse extends MasterModel.PaginationReponse {
|
||||||
|
ships: ShipDetail[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ship Group
|
||||||
|
|
||||||
|
interface ShipGroupBaseInfo {
|
||||||
|
name: string;
|
||||||
|
owner_id?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShipGroup extends ShipGroupBaseInfo {
|
||||||
|
id: string;
|
||||||
|
created_at?: Date | string;
|
||||||
|
updated_at?: Date | string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupShipResponse extends ShipGroupBaseInfo {
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShipGroupCreateParams extends ShipGroupBaseInfo {
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShipGroupUpdateParams extends ShipGroupBaseInfo {
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/services/slave/sgw/typings/trip.d.ts
vendored
Normal file
111
src/services/slave/sgw/typings/trip.d.ts
vendored
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
interface TripBasicInfo {
|
||||||
|
name: string;
|
||||||
|
departure_time: string;
|
||||||
|
arrival_time: string;
|
||||||
|
trip_status: number;
|
||||||
|
ship_name: string;
|
||||||
|
departure_port_id: number;
|
||||||
|
arrival_port_id: number;
|
||||||
|
fishing_ground_codes: number[];
|
||||||
|
trip_cost: TripCost[];
|
||||||
|
notes: string | null;
|
||||||
|
fishing_gears?: FishingGear[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Trip extends TripBasicInfo {
|
||||||
|
id: string;
|
||||||
|
ship_id: string;
|
||||||
|
ship_length: number;
|
||||||
|
vms_id: string;
|
||||||
|
crews?: TripCrews[];
|
||||||
|
total_catch_weight: number | null;
|
||||||
|
total_species_caught: number | null;
|
||||||
|
trip_status: number;
|
||||||
|
approved_by: string;
|
||||||
|
fishing_logs: FishingLog[] | null;
|
||||||
|
sync: boolean;
|
||||||
|
}
|
||||||
|
interface TripQueryParams extends MasterModel.SearchPaginationBody {
|
||||||
|
order?: string;
|
||||||
|
metadata?: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
ship_name?: string;
|
||||||
|
reg_number?: string;
|
||||||
|
province_code?: string;
|
||||||
|
owner_id?: string;
|
||||||
|
ship_id?: string;
|
||||||
|
status?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripQueryResponse extends MasterModel.PaginationReponse {
|
||||||
|
trips: Trip[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripUpdateParams extends Partial<TripBasicInfo> {
|
||||||
|
crews?: TripCrews[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripDeleteParams {
|
||||||
|
trip_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripUpdateStateRequest {
|
||||||
|
status: number;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripCreateParams extends Partial<TripBasicInfo> {
|
||||||
|
crews?: TripCrews[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripCost {
|
||||||
|
type: string;
|
||||||
|
unit: string;
|
||||||
|
amount: string;
|
||||||
|
total_cost: string;
|
||||||
|
cost_per_unit: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trip Crews
|
||||||
|
|
||||||
|
interface TripCrewPerson {
|
||||||
|
personal_id: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
birth_date: Date;
|
||||||
|
note: string;
|
||||||
|
address: string;
|
||||||
|
created_at: Date;
|
||||||
|
updated_at: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripCrews {
|
||||||
|
role: string;
|
||||||
|
joined_at: Date;
|
||||||
|
left_at: Date | null;
|
||||||
|
note: string | null;
|
||||||
|
Person: TripCrewPerson;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripCrewBasicInfo {
|
||||||
|
personal_id: string;
|
||||||
|
role: string;
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripCrewCreateParams extends Partial<TripCrewBasicInfo> {
|
||||||
|
trip_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TripCrewUpdateParams extends Partial<TripCrewBasicInfo> {
|
||||||
|
trip_id: string;
|
||||||
|
}
|
||||||
|
interface TripCrewQueryResponse {
|
||||||
|
trip_crews: TripCrews[];
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/services/slave/sgw/typings/ws.d.ts
vendored
Normal file
8
src/services/slave/sgw/typings/ws.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
declare namespace WsTypes {
|
||||||
|
interface WsThingResponse {
|
||||||
|
thing_id?: string;
|
||||||
|
key?: string;
|
||||||
|
data?: string;
|
||||||
|
time?: number;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/services/slave/sgw/typings/zone.d.ts
vendored
Normal file
59
src/services/slave/sgw/typings/zone.d.ts
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
declare namespace SgwModel {
|
||||||
|
// Banzone Management
|
||||||
|
interface ZoneBasicInfo {
|
||||||
|
name?: string;
|
||||||
|
province_code?: string;
|
||||||
|
type?: number;
|
||||||
|
conditions?: Condition[];
|
||||||
|
description?: string;
|
||||||
|
geometry?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Banzone extends ZoneBasicInfo {
|
||||||
|
id?: string;
|
||||||
|
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 ZoneBodyRequest {
|
||||||
|
name: string;
|
||||||
|
group_id: string;
|
||||||
|
type: number;
|
||||||
|
conditions: Condition[];
|
||||||
|
description?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
geom: string;
|
||||||
|
province_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZoneResponse extends Partial<MasterModel.PaginationResponse> {
|
||||||
|
banzones?: Banzone[];
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchZonePaginationBody extends MasterModel.SearchPaginationBody {
|
||||||
|
order?: string;
|
||||||
|
metadata?: {
|
||||||
|
province_code?: string;
|
||||||
|
type?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/services/slave/spole/spole.typings.d.ts
vendored
9
src/services/slave/spole/spole.typings.d.ts
vendored
@@ -2,8 +2,11 @@
|
|||||||
// 该文件由 OneAPI 自动生成,请勿手动修改!
|
// 该文件由 OneAPI 自动生成,请勿手动修改!
|
||||||
|
|
||||||
declare namespace SpoleModel {
|
declare namespace SpoleModel {
|
||||||
interface ThingMedata extends MasterModel.ThingMetadata {}
|
interface ThingMetdadata extends MasterModel.ThingReponseMetadata {
|
||||||
|
uptime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
type SpoleThingsResponse = MasterModel.ThingsResponse<SpoleModel.ThingMedata>;
|
type SpoleThingsResponse =
|
||||||
type SpoleThing = MasterModel.Thing<SpoleModel.ThingMedata>;
|
MasterModel.ThingsResponse<SpoleModel.ThingMetdadata>;
|
||||||
|
type SpoleThing = MasterModel.Thing<SpoleModel.ThingMetdadata>;
|
||||||
}
|
}
|
||||||
|
|||||||
69
src/utils/slave/sgw/fishRarity.ts
Normal file
69
src/utils/slave/sgw/fishRarity.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Fish rarity levels
|
||||||
|
*/
|
||||||
|
export enum FishRarity {
|
||||||
|
NORMAL = 1, // Phổ biến
|
||||||
|
VULNERABLE = 2, // Dễ bị tổn thương
|
||||||
|
NEAR_THREATENED = 3, // Gần bị đe dọa
|
||||||
|
ENDANGERED = 4, // Nguy cấp
|
||||||
|
CRITICALLY_ENDANGERED = 5, // Cực kỳ nguy cấp
|
||||||
|
EXTINCT_IN_WILD = 6, // Tuyệt chủng trong tự nhiên
|
||||||
|
EXTINCT = 7, // Tuyệt chủng
|
||||||
|
DATA_DEFICIENT = 8, // Thiếu dữ liệu
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rarity label by ID
|
||||||
|
* @param id Rarity level ID
|
||||||
|
* @returns Rarity label in Vietnamese
|
||||||
|
*/
|
||||||
|
export function getRarityById(id: number): string {
|
||||||
|
switch (id) {
|
||||||
|
case FishRarity.NORMAL:
|
||||||
|
return 'Phổ biến';
|
||||||
|
case FishRarity.VULNERABLE:
|
||||||
|
return 'Dễ bị tổn thương';
|
||||||
|
case FishRarity.NEAR_THREATENED:
|
||||||
|
return 'Gần bị đe dọa';
|
||||||
|
case FishRarity.ENDANGERED:
|
||||||
|
return 'Nguy cấp';
|
||||||
|
case FishRarity.CRITICALLY_ENDANGERED:
|
||||||
|
return 'Cực kỳ nguy cấp';
|
||||||
|
case FishRarity.EXTINCT_IN_WILD:
|
||||||
|
return 'Tuyệt chủng trong tự nhiên';
|
||||||
|
case FishRarity.EXTINCT:
|
||||||
|
return 'Tuyệt chủng';
|
||||||
|
case FishRarity.DATA_DEFICIENT:
|
||||||
|
return 'Thiếu dữ liệu';
|
||||||
|
default:
|
||||||
|
return 'Không xác định';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get rarity label in English
|
||||||
|
* @param id Rarity level ID
|
||||||
|
* @returns Rarity label in English
|
||||||
|
*/
|
||||||
|
export function getRarityByIdEn(id: number): string {
|
||||||
|
switch (id) {
|
||||||
|
case FishRarity.NORMAL:
|
||||||
|
return 'Common';
|
||||||
|
case FishRarity.VULNERABLE:
|
||||||
|
return 'Vulnerable';
|
||||||
|
case FishRarity.NEAR_THREATENED:
|
||||||
|
return 'Near Threatened';
|
||||||
|
case FishRarity.ENDANGERED:
|
||||||
|
return 'Endangered';
|
||||||
|
case FishRarity.CRITICALLY_ENDANGERED:
|
||||||
|
return 'Critically Endangered';
|
||||||
|
case FishRarity.EXTINCT_IN_WILD:
|
||||||
|
return 'Extinct in Wild';
|
||||||
|
case FishRarity.EXTINCT:
|
||||||
|
return 'Extinct';
|
||||||
|
case FishRarity.DATA_DEFICIENT:
|
||||||
|
return 'Data Deficient';
|
||||||
|
default:
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/utils/slave/sgw/groupUtils.ts
Normal file
18
src/utils/slave/sgw/groupUtils.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export function flattenGroupNodes(
|
||||||
|
nodes: MasterModel.GroupNode[],
|
||||||
|
): MasterModel.GroupNode[] {
|
||||||
|
const result: MasterModel.GroupNode[] = [];
|
||||||
|
|
||||||
|
function traverse(node: MasterModel.GroupNode) {
|
||||||
|
const { children, ...nodeWithoutChildren } = node;
|
||||||
|
result.push(nodeWithoutChildren);
|
||||||
|
|
||||||
|
if (children && children.length > 0) {
|
||||||
|
children.forEach((child) => traverse(child));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.forEach((node) => traverse(node));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
25
src/utils/slave/sgw/timeUtils copy.ts
Normal file
25
src/utils/slave/sgw/timeUtils copy.ts
Normal 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(),
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import ReconnectingWebSocket from 'reconnecting-websocket';
|
import ReconnectingWebSocket from 'reconnecting-websocket';
|
||||||
import { getToken } from '../../storage';
|
import { getToken } from './storage';
|
||||||
|
|
||||||
type MessageHandler = (data: any) => void;
|
type MessageHandler = (data: any) => void;
|
||||||
|
|
||||||
Reference in New Issue
Block a user