From a11e2c29913023ff7abebd6c9416314417273e4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=AA=20Tu=E1=BA=A5n=20Anh?= Date: Tue, 27 Jan 2026 12:17:11 +0700 Subject: [PATCH] feat(sgw): Implement Create or Update Banzone functionality with map integration --- src/components/shared/PhotoActionModal.tsx | 298 ++++++++ src/const.ts | 2 + src/constants/slave/sgw/routes.ts | 7 +- src/locales/en-US.ts | 3 + src/locales/en-US/slave/sgw/sgw-en.ts | 13 + src/locales/en-US/slave/sgw/sgw-fish-en.ts | 53 ++ src/locales/en-US/slave/sgw/sgw-map-en.ts | 37 + src/locales/en-US/slave/sgw/sgw-menu-en.ts | 10 +- src/locales/en-US/slave/sgw/sgw-photo-en.ts | 16 + src/locales/en-US/slave/sgw/sgw-ship-en.ts | 16 + src/locales/en-US/slave/sgw/sgw-trip-en.ts | 64 ++ src/locales/en-US/slave/sgw/sgw-zone-en.ts | 32 + src/locales/vi-VN.ts | 1 + src/locales/vi-VN/slave/sgw/sgw-fish-vi.ts | 53 ++ src/locales/vi-VN/slave/sgw/sgw-map-vi.ts | 36 + src/locales/vi-VN/slave/sgw/sgw-menu-vi.ts | 8 +- src/locales/vi-VN/slave/sgw/sgw-photo-vi.ts | 16 + src/locales/vi-VN/slave/sgw/sgw-ship-vi.ts | 16 + src/locales/vi-VN/slave/sgw/sgw-trip-vi.ts | 64 ++ src/locales/vi-VN/slave/sgw/sgw-vi.ts | 13 + src/locales/vi-VN/slave/sgw/sgw-zone-vi.ts | 31 + src/pages/Alarm/index.tsx | 5 +- .../components/AddConditionForm.tsx | 317 +++++++++ .../components/GeometryForm.tsx | 334 +++++++++ .../components/PolygonModal.tsx | 175 +++++ .../CreateOrUpdate/components/ZoneForm.tsx | 273 ++++++++ .../Area/CreateOrUpdate/hooks/index.ts | 1 + .../hooks/useMapGeometrySync.ts | 304 +++++++++ .../SGW/Manager/Area/CreateOrUpdate/index.tsx | 520 ++++++++++++++ .../SGW/Manager/Area/CreateOrUpdate/type.ts | 216 ++++++ src/pages/Slave/SGW/Manager/Area/index.tsx | 645 +++++++++++++++++- .../Fish/component/AddOrUpdateFish.tsx | 399 +++++++++++ .../SGW/Manager/Fish/component/FishImage.tsx | 66 ++ src/pages/Slave/SGW/Manager/Fish/index.tsx | 347 +++++++++- .../Slave/SGW/Map/components/ShipDetail.tsx | 4 +- .../Slave/SGW/Trip/components/TripCrews.tsx | 6 +- src/services/master/typings.d.ts | 1 + src/services/slave/sgw/FishController.ts | 36 + src/services/slave/sgw/PhotoController.ts | 66 +- src/services/slave/sgw/ZoneController.ts | 2 +- src/services/slave/sgw/typings/fish.d.ts | 50 +- src/services/slave/sgw/typings/photo.d.ts | 10 +- src/services/slave/sgw/typings/zone.d.ts | 21 + src/utils/slave/sgw/fishRarity.ts | 69 ++ src/utils/slave/sgw/groupUtils.ts | 18 + src/utils/slave/sgw/timeUtils copy.ts | 25 + 46 files changed, 4660 insertions(+), 39 deletions(-) create mode 100644 src/components/shared/PhotoActionModal.tsx create mode 100644 src/const.ts create mode 100644 src/locales/en-US/slave/sgw/sgw-fish-en.ts create mode 100644 src/locales/en-US/slave/sgw/sgw-map-en.ts create mode 100644 src/locales/en-US/slave/sgw/sgw-photo-en.ts create mode 100644 src/locales/en-US/slave/sgw/sgw-ship-en.ts create mode 100644 src/locales/en-US/slave/sgw/sgw-trip-en.ts create mode 100644 src/locales/en-US/slave/sgw/sgw-zone-en.ts create mode 100644 src/locales/vi-VN/slave/sgw/sgw-fish-vi.ts create mode 100644 src/locales/vi-VN/slave/sgw/sgw-map-vi.ts create mode 100644 src/locales/vi-VN/slave/sgw/sgw-photo-vi.ts create mode 100644 src/locales/vi-VN/slave/sgw/sgw-ship-vi.ts create mode 100644 src/locales/vi-VN/slave/sgw/sgw-trip-vi.ts create mode 100644 src/locales/vi-VN/slave/sgw/sgw-zone-vi.ts create mode 100644 src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/AddConditionForm.tsx create mode 100644 src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/GeometryForm.tsx create mode 100644 src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/PolygonModal.tsx create mode 100644 src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/ZoneForm.tsx create mode 100644 src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/hooks/index.ts create mode 100644 src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/hooks/useMapGeometrySync.ts create mode 100644 src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/index.tsx create mode 100644 src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/type.ts create mode 100644 src/pages/Slave/SGW/Manager/Fish/component/AddOrUpdateFish.tsx create mode 100644 src/pages/Slave/SGW/Manager/Fish/component/FishImage.tsx create mode 100644 src/services/slave/sgw/FishController.ts create mode 100644 src/utils/slave/sgw/fishRarity.ts create mode 100644 src/utils/slave/sgw/groupUtils.ts create mode 100644 src/utils/slave/sgw/timeUtils copy.ts diff --git a/src/components/shared/PhotoActionModal.tsx b/src/components/shared/PhotoActionModal.tsx new file mode 100644 index 0000000..8959571 --- /dev/null +++ b/src/components/shared/PhotoActionModal.tsx @@ -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>; + type: SgwModel.PhotoGetParams['type']; + id: string | number; + hasSubPhotos?: boolean; +}; +const PhotoActionModal = ({ + isOpen, + setIsOpen, + type, + id, + hasSubPhotos = true, +}: PhotoActionModalProps) => { + const [imageMain, setImageMain] = useState([]); + const [imageSubs, setImageSubs] = useState([]); + const [messageApi, contextHolder] = message.useMessage(); + const intl = useIntl(); + const fetchImageByTag = async (tag: string): Promise => { + 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 => { + 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 => { + 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 ( + { + // 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} + + {intl.formatMessage({ id: 'photo.main', defaultMessage: 'Ảnh chính' })} + +
+ ({ 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([]); + } + }, + }} + /> +
+ {hasSubPhotos && ( + <> + + {intl.formatMessage({ id: 'photo.sub', defaultMessage: 'Ảnh phụ' })} + +
0 ? 'flex-start' : 'center', + marginBottom: 16, + }} + > + ({ 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; + }, + }} + /> +
+ + )} +
+ ); +}; + +export default PhotoActionModal; diff --git a/src/const.ts b/src/const.ts new file mode 100644 index 0000000..c4142cc --- /dev/null +++ b/src/const.ts @@ -0,0 +1,2 @@ +// Re-export from constants for backward compatibility +export * from './constants'; diff --git a/src/constants/slave/sgw/routes.ts b/src/constants/slave/sgw/routes.ts index 0124e59..e08542e 100644 --- a/src/constants/slave/sgw/routes.ts +++ b/src/constants/slave/sgw/routes.ts @@ -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_UPDATE_TRIP_STATUS = '/api/sgw/update-trip-status'; export const SGW_ROUTE_HAUL_HANDLE = '/api/sgw/haul-handle'; -export const SGW_ROUTE_GET_FISH = '/api/sgw/fish-species'; +export const SGW_ROUTE_GET_FISH = '/api/sgw/fishspecies-list'; export const SGW_ROUTE_UPDATE_FISHING_LOGS = '/api/sgw/update-fishing-logs'; // Crew API Routes @@ -26,7 +26,12 @@ export const SGW_ROUTE_TRIPS_CREWS = '/api/sgw/trips/crews'; // Photo API Routes export const SGW_ROUTE_PHOTO = '/api/sgw/photo'; +export const SGW_ROUTE_PHOTO_TAGS = '/api/sgw/list-photo'; // Banzone API Routes export const SGW_ROUTE_BANZONES = '/api/sgw/banzones'; export const SGW_ROUTE_BANZONES_LIST = '/api/sgw/banzones/list'; + +// Fish API Routes +export const SGW_ROUTE_CREATE_OR_UPDATE_FISH = '/api/sgw/fishspecies'; +export const SGW_ROUTE_CREATE_OR_UPDATE_FISHING_LOGS = '/api/sgw/fishingLog'; diff --git a/src/locales/en-US.ts b/src/locales/en-US.ts index 265af27..70d6a15 100644 --- a/src/locales/en-US.ts +++ b/src/locales/en-US.ts @@ -33,8 +33,11 @@ export default { 'common.theme.dark': 'Dark Theme', 'common.paginations.things': 'things', 'common.paginations.of': 'of', + 'common.of': 'of', 'common.name': 'Name', 'common.name.required': 'Name is required', + 'common.note': 'Note', + 'common.image': 'Image', 'common.type': 'Type', 'common.type.placeholder': 'Select Type', 'common.status': 'Status', diff --git a/src/locales/en-US/slave/sgw/sgw-en.ts b/src/locales/en-US/slave/sgw/sgw-en.ts index f278df6..987e3b2 100644 --- a/src/locales/en-US/slave/sgw/sgw-en.ts +++ b/src/locales/en-US/slave/sgw/sgw-en.ts @@ -1,6 +1,19 @@ +import sgwFish from './sgw-fish-en'; +import sgwMap from './sgw-map-en'; import sgwMenu from './sgw-menu-en'; +import sgwPhoto from './sgw-photo-en'; +import sgwShip from './sgw-ship-en'; +import sgwTrip from './sgw-trip-en'; +import sgwZone from './sgw-zone-en'; + export default { 'sgw.title': 'Sea Gateway', 'sgw.ship': 'Ship', ...sgwMenu, + ...sgwTrip, + ...sgwMap, + ...sgwShip, + ...sgwFish, + ...sgwPhoto, + ...sgwZone, }; diff --git a/src/locales/en-US/slave/sgw/sgw-fish-en.ts b/src/locales/en-US/slave/sgw/sgw-fish-en.ts new file mode 100644 index 0000000..6c332b7 --- /dev/null +++ b/src/locales/en-US/slave/sgw/sgw-fish-en.ts @@ -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', +}; diff --git a/src/locales/en-US/slave/sgw/sgw-map-en.ts b/src/locales/en-US/slave/sgw/sgw-map-en.ts new file mode 100644 index 0000000..bc9dfae --- /dev/null +++ b/src/locales/en-US/slave/sgw/sgw-map-en.ts @@ -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', +}; diff --git a/src/locales/en-US/slave/sgw/sgw-menu-en.ts b/src/locales/en-US/slave/sgw/sgw-menu-en.ts index 488a33e..22c9806 100644 --- a/src/locales/en-US/slave/sgw/sgw-menu-en.ts +++ b/src/locales/en-US/slave/sgw/sgw-menu-en.ts @@ -1,7 +1,11 @@ export default { 'menu.sgw.map': 'Maps', 'menu.sgw.trips': 'Trips', - 'menu.sgw.ships': 'Ship', - 'menu.manager.sgw.fishes': 'Fishes', - 'menu.manager.sgw.zones': 'Zones', + 'menu.sgw.ships': 'Ships', + 'menu.sgw.fishes': 'Fishes', + 'menu.sgw.zones': 'Prohibited Zones', + + // Manager menu + 'menu.manager.sgw.fishes': 'Fish Management', + 'menu.manager.sgw.zones': 'Zone Management', }; diff --git a/src/locales/en-US/slave/sgw/sgw-photo-en.ts b/src/locales/en-US/slave/sgw/sgw-photo-en.ts new file mode 100644 index 0000000..d9df216 --- /dev/null +++ b/src/locales/en-US/slave/sgw/sgw-photo-en.ts @@ -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', +}; diff --git a/src/locales/en-US/slave/sgw/sgw-ship-en.ts b/src/locales/en-US/slave/sgw/sgw-ship-en.ts new file mode 100644 index 0000000..1eaf5eb --- /dev/null +++ b/src/locales/en-US/slave/sgw/sgw-ship-en.ts @@ -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', +}; diff --git a/src/locales/en-US/slave/sgw/sgw-trip-en.ts b/src/locales/en-US/slave/sgw/sgw-trip-en.ts new file mode 100644 index 0000000..b8318bf --- /dev/null +++ b/src/locales/en-US/slave/sgw/sgw-trip-en.ts @@ -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', +}; diff --git a/src/locales/en-US/slave/sgw/sgw-zone-en.ts b/src/locales/en-US/slave/sgw/sgw-zone-en.ts new file mode 100644 index 0000000..802ca90 --- /dev/null +++ b/src/locales/en-US/slave/sgw/sgw-zone-en.ts @@ -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!', +}; diff --git a/src/locales/vi-VN.ts b/src/locales/vi-VN.ts index 3e0062d..14ea434 100644 --- a/src/locales/vi-VN.ts +++ b/src/locales/vi-VN.ts @@ -32,6 +32,7 @@ export default { 'common.theme.dark': 'Tối', 'common.paginations.things': 'thiết bị', 'common.paginations.of': 'trên', + 'common.of': 'trên', 'common.name': 'Tên', 'common.name.required': 'Tên không được để trống', 'common.note': 'Ghi chú', diff --git a/src/locales/vi-VN/slave/sgw/sgw-fish-vi.ts b/src/locales/vi-VN/slave/sgw/sgw-fish-vi.ts new file mode 100644 index 0000000..5ac271a --- /dev/null +++ b/src/locales/vi-VN/slave/sgw/sgw-fish-vi.ts @@ -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', +}; diff --git a/src/locales/vi-VN/slave/sgw/sgw-map-vi.ts b/src/locales/vi-VN/slave/sgw/sgw-map-vi.ts new file mode 100644 index 0000000..e44d52d --- /dev/null +++ b/src/locales/vi-VN/slave/sgw/sgw-map-vi.ts @@ -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', +}; diff --git a/src/locales/vi-VN/slave/sgw/sgw-menu-vi.ts b/src/locales/vi-VN/slave/sgw/sgw-menu-vi.ts index 88c55c5..12241a2 100644 --- a/src/locales/vi-VN/slave/sgw/sgw-menu-vi.ts +++ b/src/locales/vi-VN/slave/sgw/sgw-menu-vi.ts @@ -2,6 +2,10 @@ export default { 'menu.sgw.map': 'Bản đồ', 'menu.sgw.trips': 'Chuyến đi', 'menu.sgw.ships': 'Quản lý tàu', - 'menu.manager.sgw.fishes': 'Loài cá', - 'menu.manager.sgw.zones': 'Khu vực', + 'menu.sgw.fishes': 'Loài cá', + 'menu.sgw.zones': 'Khu vực cấm', + + // Manager menu + 'menu.manager.sgw.fishes': 'Quản lý loài cá', + 'menu.manager.sgw.zones': 'Quản lý khu vực cấm', }; diff --git a/src/locales/vi-VN/slave/sgw/sgw-photo-vi.ts b/src/locales/vi-VN/slave/sgw/sgw-photo-vi.ts new file mode 100644 index 0000000..bd9ddb0 --- /dev/null +++ b/src/locales/vi-VN/slave/sgw/sgw-photo-vi.ts @@ -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', +}; diff --git a/src/locales/vi-VN/slave/sgw/sgw-ship-vi.ts b/src/locales/vi-VN/slave/sgw/sgw-ship-vi.ts new file mode 100644 index 0000000..c2d7971 --- /dev/null +++ b/src/locales/vi-VN/slave/sgw/sgw-ship-vi.ts @@ -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', +}; diff --git a/src/locales/vi-VN/slave/sgw/sgw-trip-vi.ts b/src/locales/vi-VN/slave/sgw/sgw-trip-vi.ts new file mode 100644 index 0000000..4dbcece --- /dev/null +++ b/src/locales/vi-VN/slave/sgw/sgw-trip-vi.ts @@ -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', +}; diff --git a/src/locales/vi-VN/slave/sgw/sgw-vi.ts b/src/locales/vi-VN/slave/sgw/sgw-vi.ts index b900736..eb97b00 100644 --- a/src/locales/vi-VN/slave/sgw/sgw-vi.ts +++ b/src/locales/vi-VN/slave/sgw/sgw-vi.ts @@ -1,6 +1,19 @@ +import sgwFish from './sgw-fish-vi'; +import sgwMap from './sgw-map-vi'; import sgwMenu from './sgw-menu-vi'; +import sgwPhoto from './sgw-photo-vi'; +import sgwShip from './sgw-ship-vi'; +import sgwTrip from './sgw-trip-vi'; +import sgwZone from './sgw-zone-vi'; + export default { 'sgw.title': 'Hệ thống giám sát tàu cá', 'sgw.ship': 'Tàu', ...sgwMenu, + ...sgwTrip, + ...sgwMap, + ...sgwShip, + ...sgwFish, + ...sgwPhoto, + ...sgwZone, }; diff --git a/src/locales/vi-VN/slave/sgw/sgw-zone-vi.ts b/src/locales/vi-VN/slave/sgw/sgw-zone-vi.ts new file mode 100644 index 0000000..d86e61a --- /dev/null +++ b/src/locales/vi-VN/slave/sgw/sgw-zone-vi.ts @@ -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!', +}; diff --git a/src/pages/Alarm/index.tsx b/src/pages/Alarm/index.tsx index 78977c4..b0bd733 100644 --- a/src/pages/Alarm/index.tsx +++ b/src/pages/Alarm/index.tsx @@ -10,7 +10,7 @@ import { FilterOutlined, } from '@ant-design/icons'; import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components'; -import { FormattedMessage, useIntl } from '@umijs/max'; +import { FormattedMessage, useIntl, useModel } from '@umijs/max'; import { Button, Flex, message, Popconfirm, Progress, Tooltip } from 'antd'; import moment from 'moment'; import { useRef, useState } from 'react'; @@ -24,6 +24,9 @@ const AlarmPage = () => { const [thingFilterDatas, setThingFilterDatas] = useState([]); const intl = useIntl(); const [messageApi, contextHolder] = message.useMessage(); + const { initialState } = useModel('@@initialState'); + const { currentUserProfile } = initialState || {}; + const columns: ProColumns[] = [ { title: intl.formatMessage({ diff --git a/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/AddConditionForm.tsx b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/AddConditionForm.tsx new file mode 100644 index 0000000..cd94179 --- /dev/null +++ b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/AddConditionForm.tsx @@ -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 ( + + + + + + + {selectedType !== undefined && + (selectedType === 'length_limit' ? ( + + + + + + + + + + + ) : ( + + + + ))} + + remove(field.name)} /> + + + ); +}; + +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 ( + { + if (!open) form.resetFields(); + setVisible(open); + }} + title={ + + } + width="600px" + submitter={{ + searchConfig: { submitText: 'Lưu' }, + render: (_, doms) => ( + + {doms} + + ), + }} + onFinish={handleSubmit} + > + + {(fields, { add, remove }) => ( + <> + {fields.map((field) => ( + + ))} + + + + + + + )} + + + ); +}; + +export default AddConditionForm; diff --git a/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/GeometryForm.tsx b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/GeometryForm.tsx new file mode 100644 index 0000000..0c91c1b --- /dev/null +++ b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/GeometryForm.tsx @@ -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 | null | undefined>; + zoneData?: SgwModel.Geom; +}; + +const GeometryForm = ({ shape, form }: GeometryFormProps) => { + const [isPolygonModalOpen, setIsPolygonModalOpen] = useState(false); + const [indexTag, setIndexTag] = useState(-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 ( + <> + + + {polygonGeometry.map((polygon, index) => ( + + { + e.preventDefault(); + handleRemovePolygon(index); + }} + onClick={() => handleEditPolygon(index)} + color="blue" + style={{ cursor: 'pointer' }} + > + {intl.formatMessage({ + id: 'banzones.title', + defaultMessage: 'Khu vực', + })}{' '} + {index + 1} + + + ))} + + + + { + form.current?.setFieldsValue({ + [ZoneFormField.PolygonGeometry]: values, + }); + setIndexTag(-1); + setIsPolygonModalOpen(false); + }} + /> + + ); + + case GeometryType.LINESTRING: + return ( + { + 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],...]', + })} + > + + + ); + + case GeometryType.CIRCLE: + return ( + <> + + + { + 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], + }, + }); + }, + }} + /> + + + { + 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], + }, + }); + }, + }} + /> + + + + + { + 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!', + }), + }, + ]} + /> + + + + 0 + ? `${radiusArea} ${intl.formatMessage({ + id: 'banzone.geometry.metrics', + defaultMessage: 'mét', + })}` + : '' + } + addonAfter={intl.formatMessage({ + id: 'banzone.geometry.auto_calculate', + defaultMessage: 'Tự động tính', + })} + /> + + + + + ); + + default: + return
Vui lòng chọn loại hình học để nhập toạ độ.
; + } +}; + +export default GeometryForm; diff --git a/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/PolygonModal.tsx b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/PolygonModal.tsx new file mode 100644 index 0000000..53c4b68 --- /dev/null +++ b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/PolygonModal.tsx @@ -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; +} + +const PolygonModal = ({ + isVisible, + setVisible, + handleSubmit, + initialData, + index, +}: PolygonModalProps) => { + const formRef = useRef(); + 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 */} + + { + 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) => ( + + {doms} + + ), + }} + onFinish={handleFinish} + > + { + const currentIndex = itemIndex++; + const isHighlighted = index !== undefined && currentIndex === index; + return ( +
+ {listDom} + + {action} + +
+ ); + }} + > + {/* Hidden field for id - needed to preserve id when editing */} + + { + 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],...]', + })} + > + + +
+
+ + ); +}; + +export default PolygonModal; diff --git a/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/ZoneForm.tsx b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/ZoneForm.tsx new file mode 100644 index 0000000..d89dd30 --- /dev/null +++ b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/components/ZoneForm.tsx @@ -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 | 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 ( + <> + + {/* Tên */} + + + + + {/* Loại */} + + + + + + {/* Tỉnh */} + + + + + + + {/* Có hiệu lực */} + + + + + + + + {(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 ( + + setIsConditionModalOpen(true)} + onClose={(e) => { + e.preventDefault(); + handleConditionsClose(index); + }} + color={ + type === 'month_range' + ? 'blue' + : type === 'date_range' + ? 'green' + : 'volcano' + } + > + {label} + + + ); + })} + + + + + { + try { + formRef.current?.setFieldValue( + ZoneFormField.AreaConditions, + newConditions, + ); + } catch (e) { + console.error('Error setting form value:', e); + } + }} + /> + + ); +}; + +export default ZoneForm; diff --git a/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/hooks/index.ts b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/hooks/index.ts new file mode 100644 index 0000000..ea9ea6a --- /dev/null +++ b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/hooks/index.ts @@ -0,0 +1 @@ +export { useMapGeometrySync } from './useMapGeometrySync'; diff --git a/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/hooks/useMapGeometrySync.ts b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/hooks/useMapGeometrySync.ts new file mode 100644 index 0000000..eefab7c --- /dev/null +++ b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/hooks/useMapGeometrySync.ts @@ -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; + dataLayerId: string; + form: MutableRefObject | 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; diff --git a/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/index.tsx b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/index.tsx new file mode 100644 index 0000000..87dab7f --- /dev/null +++ b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/index.tsx @@ -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>(); + const vietNamMapRef = useRef(null); + const baseMap = useRef(null); + const drawController = useRef(null); + const [loading, setLoading] = useState(false); + const [formReady, setFormReady] = useState(false); + const { groupMap } = useModel('master.useGroups'); + const [mapActions, setMapActions] = useState({ + 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 ( + + ) : ( + + ) + } + style={{ + padding: 10, + }} + > + + + + 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 = { + [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 ( + + {dom} + + ); + }, + }} + > + + + + + + + + + + + + + + ); +}; + +export default CreateOrUpdateBanzone; diff --git a/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/type.ts b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/type.ts new file mode 100644 index 0000000..ae50d15 --- /dev/null +++ b/src/pages/Slave/SGW/Manager/Area/CreateOrUpdate/type.ts @@ -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; + } +}; diff --git a/src/pages/Slave/SGW/Manager/Area/index.tsx b/src/pages/Slave/SGW/Manager/Area/index.tsx index dad46fb..7e1a13c 100644 --- a/src/pages/Slave/SGW/Manager/Area/index.tsx +++ b/src/pages/Slave/SGW/Manager/Area/index.tsx @@ -1,11 +1,644 @@ -import React from 'react'; +import IconFont from '@/components/IconFont'; +import TreeGroup from '@/components/shared/TreeGroup'; +import { DEFAULT_PAGE_SIZE } from '@/const'; +import { + SGW_ROUTE_BANZONES, + SGW_ROUTE_BANZONES_LIST, +} from '@/constants/slave/sgw/routes'; + +import { + apiGetAllBanzones, + apiRemoveBanzone, +} from '@/services/slave/sgw/ZoneController'; +import { flattenGroupNodes } from '@/utils/slave/sgw/groupUtils'; +import { formatDate } from '@/utils/slave/sgw/timeUtils'; +import { DeleteOutlined, DownOutlined, EditOutlined } from '@ant-design/icons'; +import { + ActionType, + ProCard, + ProColumns, + ProTable, +} from '@ant-design/pro-components'; +import { FormattedMessage, history, useIntl, useModel } from '@umijs/max'; +import { + Button, + Dropdown, + Flex, + Grid, + message, + Popconfirm, + Space, + Tag, + Tooltip, + Typography, +} from 'antd'; +import { MenuProps } from 'antd/lib'; +import { useEffect, useRef, useState } from 'react'; +const { Paragraph, Text } = Typography; + +const BanZoneList = () => { + const { useBreakpoint } = Grid; + const intl = useIntl(); + const screens = useBreakpoint(); + const tableRef = useRef(); + const [groupCheckedKeys, setGroupCheckedKeys] = useState(''); + const { groups, getGroups } = useModel('master.useGroups'); + const groupFlattened = flattenGroupNodes(groups || []); + const [messageApi, contextHolder] = message.useMessage(); + const [selectedRowsState, setSelectedRows] = useState([]); + 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[] = [ + { + key: 'name', + title: , + dataIndex: 'name', + render: (_, record) => ( +
+ + {record?.name} + +
+ ), + width: '15%', + }, + { + key: 'group', + title: , + 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 ( + + {matchedMember?.name || '-'} + + ); + }, + width: '15%', + }, + { + key: 'description', + title: ( + + ), + dataIndex: 'description', + hideInSearch: true, + render: (_, record) => ( + + {record?.description || '-'} + + ), + width: '15%', + }, + { + key: 'type', + title: , + 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) => ( + + {record.type === 1 + ? intl.formatMessage({ + id: 'banzone.area.fishing_ban', + defaultMessage: 'Fishing Ban', + }) + : intl.formatMessage({ + id: 'banzone.area.move_ban', + defaultMessage: 'Movement Ban', + })} + + ), + width: 120, + }, + { + key: 'conditions', + title: ( + + ), + dataIndex: 'conditions', + hideInSearch: true, + render: (conditions) => { + if (!Array.isArray(conditions)) return null; + return ( + + {conditions.map((cond, index) => { + switch (cond.type) { + case 'month_range': + return ( + + + Th.{cond.from} - Th.{cond.to} + + + ); + case 'date_range': + return ( + + + {formatDate(cond.from)} → {formatDate(cond.to)} + + + ); + case 'length_limit': + return ( + + + {cond.min}-{cond.max}m + + + ); + default: + return null; + } + })} + + ); + }, + width: 180, + }, + { + key: 'enabled', + title: ( + + ), + 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 ( + + {record.enabled === true + ? intl.formatMessage({ + id: 'banzone.is_enable', + defaultMessage: 'Enabled', + }) + : intl.formatMessage({ + id: 'banzone.is_unenabled', + defaultMessage: 'Disabled', + })} + + ); + }, + width: 120, + }, + { + title: , + hideInSearch: true, + width: 120, + fixed: 'right', + render: (_, record) => [ + + + handleDelete(record)} + okText={intl.formatMessage({ + id: 'common.delete', + defaultMessage: 'Delete', + })} + cancelText={intl.formatMessage({ + id: 'common.cancel', + defaultMessage: 'Cancel', + })} + okType="danger" + > + + + , + ], + }, + ]; + + const items: Required['items'] = [ + { + label: intl.formatMessage({ + id: 'banzone.polygon', + defaultMessage: 'Polygon', + }), + onClick: () => { + history.push(SGW_ROUTE_BANZONES, { + shape: 1, + type: 'create', + }); + }, + key: '0', + icon: , + }, + { + label: intl.formatMessage({ + id: 'banzone.polyline', + defaultMessage: 'Polyline', + }), + key: '1', + onClick: () => { + history.push(SGW_ROUTE_BANZONES, { + shape: 2, + type: 'create', + }); + }, + icon: , + }, + { + label: intl.formatMessage({ + id: 'banzone.circle', + defaultMessage: 'Circle', + }), + key: '3', + onClick: () => { + history.push(SGW_ROUTE_BANZONES, { + shape: 3, + type: 'create', + }); + }, + icon: , + }, + ]; + + const deleteMultipleBanzones = async (records: SgwModel.Banzone[]) => { + const key = 'deleteMultiple'; + messageApi.open({ + key, + type: 'loading', + content: intl.formatMessage({ + id: 'common.deleting', + defaultMessage: 'Deleting...', + }), + duration: 0, + }); + try { + for (const record of records) { + const groupID = groupFlattened.find( + (m) => m.metadata.code === record.province_code, + )?.id; + + await apiRemoveBanzone(record.id || '', groupID || ''); + } + messageApi.open({ + key, + type: 'success', + content: `Đã xoá thành công ${records.length} khu vực`, + duration: 2, + }); + tableRef.current?.reload(); + } catch (error) { + console.error('Error deleting area:', error); + messageApi.open({ + key, + type: 'error', + content: intl.formatMessage({ + id: 'banzone.notify.fail', + defaultMessage: 'Delete zone failed!', + }), + duration: 2, + }); + } + }; -const SGWArea: React.FC = () => { return ( -
-

Khu vực (SGW Manager)

-
+ <> + {contextHolder} + + + { + // 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(); + }} + /> + + + + 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 }) => ( +
Đã chọn {selectedRowKeys.length} mục
+ )} + tableAlertOptionRender={({ selectedRows, onCleanSelected }) => { + return ( + + { + deleteMultipleBanzones(selectedRows); + }} + okText={intl.formatMessage({ + id: 'common.sure', + defaultMessage: 'Chắc chắn', + })} + cancelText={intl.formatMessage({ + id: 'common.no', + defaultMessage: 'Không', + })} + > + + + + + + ); + }} + toolBarRender={() => [ + + + , + ]} + /> +
+
+ ); }; -export default SGWArea; +export default BanZoneList; diff --git a/src/pages/Slave/SGW/Manager/Fish/component/AddOrUpdateFish.tsx b/src/pages/Slave/SGW/Manager/Fish/component/AddOrUpdateFish.tsx new file mode 100644 index 0000000..daa209a --- /dev/null +++ b/src/pages/Slave/SGW/Manager/Fish/component/AddOrUpdateFish.tsx @@ -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>; + message: MessageInstance; + onReload?: (isSuccess: boolean) => void; +}; +const AddOrUpdateFish = ({ + type, + fish, + isOpen, + setIsOpen, + message, + onReload, +}: AddOrUpdateFishProps) => { + const formRef = useRef>(); + const [fileList, setFileList] = useState([]); + const [originalFileList, setOriginalFileList] = useState([]); + 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 ( + + 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' && ( +
+ ({ upload: value })} + fieldProps={{ + onChange(info) { + setFileList(info.fileList); + }, + listType: 'picture-card', + fileList: fileList, + onRemove: () => { + setFileList([]); + }, + }} + /> +
+ )} + + + + + + + + + + + + ); +}; + +export default AddOrUpdateFish; diff --git a/src/pages/Slave/SGW/Manager/Fish/component/FishImage.tsx b/src/pages/Slave/SGW/Manager/Fish/component/FishImage.tsx new file mode 100644 index 0000000..ba29248 --- /dev/null +++ b/src/pages/Slave/SGW/Manager/Fish/component/FishImage.tsx @@ -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(''); + const [loading, setLoading] = useState(true); + const objectUrlRef = useRef(''); + + 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 ; + } + + if (!url) { + return -; + } + + return {alt}; +}; diff --git a/src/pages/Slave/SGW/Manager/Fish/index.tsx b/src/pages/Slave/SGW/Manager/Fish/index.tsx index cb02947..e8c1280 100644 --- a/src/pages/Slave/SGW/Manager/Fish/index.tsx +++ b/src/pages/Slave/SGW/Manager/Fish/index.tsx @@ -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(); + const intl = useIntl(); + const [messageApi, contextHolder] = message.useMessage(); + const [showAddOrUpdateModal, setShowAddOrUpdateModal] = + useState(false); + const [fishSelected, setFishSelected] = useState( + undefined, + ); + const [fishPhotoModalOpen, setFishPhotoModalOpen] = useState(false); + const [fishID, setFishID] = useState(undefined); + const [isReloadImage, setIsReloadImage] = useState(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[] = [ + { + title: intl.formatMessage({ + id: 'common.name', + defaultMessage: 'Name', + }), + dataIndex: 'name', + key: 'name', + copyable: true, + render: (dom, entity) => { + return ( + + {dom} + + ); + }, + }, + { + title: intl.formatMessage({ + id: 'common.image', + defaultMessage: 'Image', + }), + dataIndex: 'id', + key: 'id', + hideInSearch: true, + // valueType: 'image', + render: (_, entity) => { + return ( + + ); + }, + }, + { + 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 ( + + {rarity || + intl.formatMessage({ + id: 'common.undefined', + defaultMessage: 'Undefined', + })} + + ); + }, + }, + { + 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 ( + + + + handleDeleteFish(entity.id?.toString() || '')} + okText={intl.formatMessage({ + id: 'common.yes', + defaultMessage: 'Yes', + })} + cancelText={intl.formatMessage({ + id: 'common.no', + defaultMessage: 'No', + })} + > + + + + ); + }, + }, + ]; -const SGWFish: React.FC = () => { return (
-

Cá (SGW Manager)

+ {contextHolder} + { + if (isSuccess) { + tableRef.current?.reload(); + setIsReloadImage((prev) => !prev); + } + }} + /> + + + 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={() => [ + , + ]} + 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, + }; + } + }} + />
); }; -export default SGWFish; +export default FishList; diff --git a/src/pages/Slave/SGW/Map/components/ShipDetail.tsx b/src/pages/Slave/SGW/Map/components/ShipDetail.tsx index b1493db..65070a0 100644 --- a/src/pages/Slave/SGW/Map/components/ShipDetail.tsx +++ b/src/pages/Slave/SGW/Map/components/ShipDetail.tsx @@ -72,8 +72,8 @@ const ShipDetail = ({ } try { - const photoData = await apiGetPhoto('ship', resp.id || ''); - const blob = new Blob([photoData], { type: 'image/jpeg' }); + const photoResponse = await apiGetPhoto('ship', resp.id || ''); + const blob = new Blob([photoResponse.data], { type: 'image/jpeg' }); const url = URL.createObjectURL(blob); setShipImage(url); } catch (e) { diff --git a/src/pages/Slave/SGW/Trip/components/TripCrews.tsx b/src/pages/Slave/SGW/Trip/components/TripCrews.tsx index ffe07c6..f29e583 100644 --- a/src/pages/Slave/SGW/Trip/components/TripCrews.tsx +++ b/src/pages/Slave/SGW/Trip/components/TripCrews.tsx @@ -49,7 +49,7 @@ const uploadPhoto = async ( type: 'people', id: string, file: File, -): Promise => { +): Promise<{ status: number }> => { return apiUploadPhoto(type, id, file); }; @@ -71,11 +71,11 @@ const CrewPhoto: React.FC<{ record: SgwModel.TripCrews }> = ({ record }) => { } try { - const photoData = await apiGetPhoto( + const photoResponse = await apiGetPhoto( 'people', 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); setPhotoSrc(url); } catch (error) { diff --git a/src/services/master/typings.d.ts b/src/services/master/typings.d.ts index 004803e..c811121 100644 --- a/src/services/master/typings.d.ts +++ b/src/services/master/typings.d.ts @@ -17,3 +17,4 @@ declare namespace MasterModel { direction?: string; } } +7; diff --git a/src/services/slave/sgw/FishController.ts b/src/services/slave/sgw/FishController.ts new file mode 100644 index 0000000..4a3843d --- /dev/null +++ b/src/services/slave/sgw/FishController.ts @@ -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 { + return request(SGW_ROUTE_GET_FISH, { + method: 'POST', + data: body, + }); +} + +export async function apiCreateFishSpecies(body?: SgwModel.Fish) { + return request(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, + }); +} diff --git a/src/services/slave/sgw/PhotoController.ts b/src/services/slave/sgw/PhotoController.ts index c2e8f6a..60bee0e 100644 --- a/src/services/slave/sgw/PhotoController.ts +++ b/src/services/slave/sgw/PhotoController.ts @@ -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'; /** * 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 - * @returns Photo as ArrayBuffer + * @param tag Photo tag (default: 'main') + * @returns Photo response with ArrayBuffer data */ export async function apiGetPhoto( type: SgwModel.PhotoGetParams['type'], - id: string, -): Promise { - return request(`${SGW_ROUTE_PHOTO}/${type}/${id}/main`, { - method: 'GET', - responseType: 'arraybuffer', - }); + id: string | number, + tag: string = 'main', +): Promise<{ status: number; data: ArrayBuffer }> { + const response = await request( + `${SGW_ROUTE_PHOTO}/${type}/${id}/${tag}`, + { + method: 'GET', + responseType: 'arraybuffer', + getResponse: true, + }, + ); + + return { + status: 200, + data: response.data, + }; +} + +export async function apiGetTagsPhoto( + type: SgwModel.PhotoGetParams['type'], + id: string | number, +) { + return request( + `${SGW_ROUTE_PHOTO_TAGS}/${type}/${id}`, + ); } /** * 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 file File to upload + * @param tag Photo tag (default: 'main') */ export async function apiUploadPhoto( type: SgwModel.PhotoUploadParams['type'], id: string, file: File, -): Promise { + tag: string = 'main', +): Promise<{ status: number }> { const formData = new FormData(); formData.append('file', file); - return request(`${SGW_ROUTE_PHOTO}/${type}/${id}/main`, { + await request(`${SGW_ROUTE_PHOTO}/${type}/${id}/${tag}`, { method: 'POST', 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 }; } diff --git a/src/services/slave/sgw/ZoneController.ts b/src/services/slave/sgw/ZoneController.ts index 7463230..15977ef 100644 --- a/src/services/slave/sgw/ZoneController.ts +++ b/src/services/slave/sgw/ZoneController.ts @@ -9,7 +9,7 @@ import { request } from '@umijs/max'; * @param body Search and pagination parameters */ export async function apiGetAllBanzones( - body: MasterModel.SearchPaginationBody, + body: SgwModel.SearchZonePaginationBody, ) { return request(SGW_ROUTE_BANZONES_LIST, { method: 'POST', diff --git a/src/services/slave/sgw/typings/fish.d.ts b/src/services/slave/sgw/typings/fish.d.ts index 054fa27..79dbf4b 100644 --- a/src/services/slave/sgw/typings/fish.d.ts +++ b/src/services/slave/sgw/typings/fish.d.ts @@ -1,8 +1,54 @@ declare namespace SgwModel { interface FishSpeciesResponse { - id: number; + fishes: Fish[]; + total: number; + } + + interface FishCreateRequest { name: string; scientific_name?: string; - description?: 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; + }; } } diff --git a/src/services/slave/sgw/typings/photo.d.ts b/src/services/slave/sgw/typings/photo.d.ts index 1653f42..926e7f2 100644 --- a/src/services/slave/sgw/typings/photo.d.ts +++ b/src/services/slave/sgw/typings/photo.d.ts @@ -5,13 +5,19 @@ declare namespace SgwModel { // } interface PhotoGetParams { - type: 'ship' | 'people'; + type: 'ship' | 'people' | 'fish'; id: string; + tag: 'main' | string; } interface PhotoUploadParams { - type: 'ship' | 'people'; + type: 'ship' | 'people' | 'fish'; id: string; file: File; + tag: 'main' | string; + } + + interface GetTagsResponse { + tags?: string[]; } } diff --git a/src/services/slave/sgw/typings/zone.d.ts b/src/services/slave/sgw/typings/zone.d.ts index c2dadd0..95d3008 100644 --- a/src/services/slave/sgw/typings/zone.d.ts +++ b/src/services/slave/sgw/typings/zone.d.ts @@ -32,7 +32,28 @@ declare namespace SgwModel { 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 { banzones?: Banzone[]; + total?: number; + } + + interface SearchZonePaginationBody extends MasterModel.SearchPaginationBody { + order?: string; + metadata?: { + province_code?: string; + type?: number; + enabled?: boolean; + }; } } diff --git a/src/utils/slave/sgw/fishRarity.ts b/src/utils/slave/sgw/fishRarity.ts new file mode 100644 index 0000000..7f3e279 --- /dev/null +++ b/src/utils/slave/sgw/fishRarity.ts @@ -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'; + } +} diff --git a/src/utils/slave/sgw/groupUtils.ts b/src/utils/slave/sgw/groupUtils.ts new file mode 100644 index 0000000..e84b405 --- /dev/null +++ b/src/utils/slave/sgw/groupUtils.ts @@ -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; +} diff --git a/src/utils/slave/sgw/timeUtils copy.ts b/src/utils/slave/sgw/timeUtils copy.ts new file mode 100644 index 0000000..8067e50 --- /dev/null +++ b/src/utils/slave/sgw/timeUtils copy.ts @@ -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(), + )}`; +}