feat(sgw): Implement Create or Update Banzone functionality with map integration
This commit is contained in:
@@ -1,11 +1,644 @@
|
||||
import React from 'react';
|
||||
import IconFont from '@/components/IconFont';
|
||||
import TreeGroup from '@/components/shared/TreeGroup';
|
||||
import { DEFAULT_PAGE_SIZE } from '@/const';
|
||||
import {
|
||||
SGW_ROUTE_BANZONES,
|
||||
SGW_ROUTE_BANZONES_LIST,
|
||||
} from '@/constants/slave/sgw/routes';
|
||||
|
||||
import {
|
||||
apiGetAllBanzones,
|
||||
apiRemoveBanzone,
|
||||
} from '@/services/slave/sgw/ZoneController';
|
||||
import { flattenGroupNodes } from '@/utils/slave/sgw/groupUtils';
|
||||
import { formatDate } from '@/utils/slave/sgw/timeUtils';
|
||||
import { DeleteOutlined, DownOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
ActionType,
|
||||
ProCard,
|
||||
ProColumns,
|
||||
ProTable,
|
||||
} from '@ant-design/pro-components';
|
||||
import { FormattedMessage, history, useIntl, useModel } from '@umijs/max';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Grid,
|
||||
message,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { MenuProps } from 'antd/lib';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
const BanZoneList = () => {
|
||||
const { useBreakpoint } = Grid;
|
||||
const intl = useIntl();
|
||||
const screens = useBreakpoint();
|
||||
const tableRef = useRef<ActionType>();
|
||||
const [groupCheckedKeys, setGroupCheckedKeys] = useState<string>('');
|
||||
const { groups, getGroups } = useModel('master.useGroups');
|
||||
const groupFlattened = flattenGroupNodes(groups || []);
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
const [selectedRowsState, setSelectedRows] = useState<SgwModel.Banzone[]>([]);
|
||||
useEffect(() => {
|
||||
if (groups === null) {
|
||||
getGroups();
|
||||
}
|
||||
}, [groups]);
|
||||
|
||||
// Reload table khi groups được load
|
||||
useEffect(() => {
|
||||
if (groups && groups.length > 0 && tableRef.current) {
|
||||
tableRef.current.reload();
|
||||
}
|
||||
}, [groups]);
|
||||
|
||||
const handleEdit = (record: SgwModel.Banzone) => {
|
||||
console.log('record: ', record);
|
||||
let geomType = 1; // Default: Polygon
|
||||
try {
|
||||
if (record.geometry) {
|
||||
const geometry: SgwModel.Geom = JSON.parse(record.geometry);
|
||||
geomType = geometry.geom_type || 1;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse geometry:', e);
|
||||
}
|
||||
history.push(`${SGW_ROUTE_BANZONES_LIST}/${record.id}`, {
|
||||
type: 'update',
|
||||
shape: geomType,
|
||||
});
|
||||
};
|
||||
const handleDelete = async (record: SgwModel.Banzone) => {
|
||||
try {
|
||||
const groupID = groupFlattened.find(
|
||||
(m) => m.metadata.code === record.province_code,
|
||||
)?.id;
|
||||
await apiRemoveBanzone(record.id || '', groupID || '');
|
||||
messageApi.success(
|
||||
intl.formatMessage({
|
||||
id: 'banzone.notify.delete_zone_success',
|
||||
defaultMessage: 'Zone deleted successfully',
|
||||
}),
|
||||
);
|
||||
// Reload lại bảng
|
||||
if (tableRef.current) {
|
||||
tableRef.current.reload();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting area:', error);
|
||||
messageApi.error(
|
||||
intl.formatMessage({
|
||||
id: 'banzone.notify.fail',
|
||||
defaultMessage: 'Delete zone failed!',
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
const columns: ProColumns<SgwModel.Banzone>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: <FormattedMessage id="banzones.name" defaultMessage="Name" />,
|
||||
dataIndex: 'name',
|
||||
render: (_, record) => (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
<Paragraph
|
||||
copyable
|
||||
style={{ margin: 0 }}
|
||||
ellipsis={{ rows: 999, tooltip: record?.name }}
|
||||
>
|
||||
{record?.name}
|
||||
</Paragraph>
|
||||
</div>
|
||||
),
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
key: 'group',
|
||||
title: <FormattedMessage id="banzones.area" defaultMessage="Province" />,
|
||||
dataIndex: 'province_code',
|
||||
hideInSearch: true,
|
||||
responsive: ['lg', 'md'],
|
||||
ellipsis: true,
|
||||
render: (_, record) => {
|
||||
const matchedMember =
|
||||
groupFlattened.find(
|
||||
(group) => group.metadata.code === record.province_code,
|
||||
) ?? null;
|
||||
return (
|
||||
<Text ellipsis={{ tooltip: matchedMember?.name || '-' }}>
|
||||
{matchedMember?.name || '-'}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
title: (
|
||||
<FormattedMessage id="banzones.description" defaultMessage="Mô tả" />
|
||||
),
|
||||
dataIndex: 'description',
|
||||
hideInSearch: true,
|
||||
render: (_, record) => (
|
||||
<Paragraph
|
||||
ellipsis={{ rows: 2, tooltip: record?.description }}
|
||||
style={{
|
||||
margin: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{record?.description || '-'}
|
||||
</Paragraph>
|
||||
),
|
||||
width: '15%',
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
title: <FormattedMessage id="banzones.type" defaultMessage="Loại" />,
|
||||
dataIndex: 'type',
|
||||
valueType: 'select',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.area.fishing_ban',
|
||||
defaultMessage: 'Fishing Ban',
|
||||
}),
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.area.move_ban',
|
||||
defaultMessage: 'Movement Ban',
|
||||
}),
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.area.safe',
|
||||
defaultMessage: 'Safe Area',
|
||||
}),
|
||||
value: 3,
|
||||
},
|
||||
],
|
||||
},
|
||||
render: (_, record) => (
|
||||
<Tag color={record.type === 1 ? '#f50' : 'orange'}>
|
||||
{record.type === 1
|
||||
? intl.formatMessage({
|
||||
id: 'banzone.area.fishing_ban',
|
||||
defaultMessage: 'Fishing Ban',
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: 'banzone.area.move_ban',
|
||||
defaultMessage: 'Movement Ban',
|
||||
})}
|
||||
</Tag>
|
||||
),
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
key: 'conditions',
|
||||
title: (
|
||||
<FormattedMessage id="banzones.conditions" defaultMessage="Điều kiện" />
|
||||
),
|
||||
dataIndex: 'conditions',
|
||||
hideInSearch: true,
|
||||
render: (conditions) => {
|
||||
if (!Array.isArray(conditions)) return null;
|
||||
return (
|
||||
<Space direction="vertical" size={4}>
|
||||
{conditions.map((cond, index) => {
|
||||
switch (cond.type) {
|
||||
case 'month_range':
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
title={`Áp dụng từ tháng ${cond.from} đến tháng ${cond.to} hàng năm`}
|
||||
>
|
||||
<Tag
|
||||
color="geekblue"
|
||||
style={{ borderRadius: 8, margin: 0 }}
|
||||
>
|
||||
Th.{cond.from} - Th.{cond.to}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'date_range':
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
title={`Áp dụng từ ${formatDate(
|
||||
cond.from,
|
||||
)} đến ${formatDate(cond.to)}`}
|
||||
>
|
||||
<Tag color="green" style={{ borderRadius: 8, margin: 0 }}>
|
||||
{formatDate(cond.from)} → {formatDate(cond.to)}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'length_limit':
|
||||
return (
|
||||
<Tooltip
|
||||
key={index}
|
||||
title={`Tàu từ ${cond.min} đến ${cond.max} mét`}
|
||||
>
|
||||
<Tag color="cyan" style={{ borderRadius: 8, margin: 0 }}>
|
||||
{cond.min}-{cond.max}m
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
title: (
|
||||
<FormattedMessage id="banzones.state" defaultMessage="Trạng thái" />
|
||||
),
|
||||
dataIndex: 'enabled',
|
||||
valueType: 'select',
|
||||
fieldProps: {
|
||||
options: [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.is_enable',
|
||||
defaultMessage: 'Enabled',
|
||||
}),
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.is_unenabled',
|
||||
defaultMessage: 'Disabled',
|
||||
}),
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
hideInSearch: false,
|
||||
responsive: ['lg', 'md'],
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<Tag color={record.enabled === true ? '#08CB00' : '#DCDCDC'}>
|
||||
{record.enabled === true
|
||||
? intl.formatMessage({
|
||||
id: 'banzone.is_enable',
|
||||
defaultMessage: 'Enabled',
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: 'banzone.is_unenabled',
|
||||
defaultMessage: 'Disabled',
|
||||
})}
|
||||
</Tag>
|
||||
);
|
||||
},
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: <FormattedMessage id="banzones.action" defaultMessage="Action" />,
|
||||
hideInSearch: true,
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
render: (_, record) => [
|
||||
<Space key="actions">
|
||||
<Button
|
||||
key="edit"
|
||||
type="primary"
|
||||
icon={<EditOutlined />}
|
||||
size="small"
|
||||
onClick={() => handleEdit(record)}
|
||||
></Button>
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title={intl.formatMessage({
|
||||
id: 'common.delete_confirm',
|
||||
defaultMessage: 'Confirm delete?',
|
||||
})}
|
||||
description={`${intl.formatMessage({
|
||||
id: 'banzone.notify.delete_zone_confirm',
|
||||
defaultMessage: 'Are you sure you want to delete this zone',
|
||||
})} "${record.name}"?`}
|
||||
onConfirm={() => handleDelete(record)}
|
||||
okText={intl.formatMessage({
|
||||
id: 'common.delete',
|
||||
defaultMessage: 'Delete',
|
||||
})}
|
||||
cancelText={intl.formatMessage({
|
||||
id: 'common.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
})}
|
||||
okType="danger"
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
></Button>
|
||||
</Popconfirm>
|
||||
</Space>,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const items: Required<MenuProps>['items'] = [
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.polygon',
|
||||
defaultMessage: 'Polygon',
|
||||
}),
|
||||
onClick: () => {
|
||||
history.push(SGW_ROUTE_BANZONES, {
|
||||
shape: 1,
|
||||
type: 'create',
|
||||
});
|
||||
},
|
||||
key: '0',
|
||||
icon: <IconFont type="icon-polygon" />,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.polyline',
|
||||
defaultMessage: 'Polyline',
|
||||
}),
|
||||
key: '1',
|
||||
onClick: () => {
|
||||
history.push(SGW_ROUTE_BANZONES, {
|
||||
shape: 2,
|
||||
type: 'create',
|
||||
});
|
||||
},
|
||||
icon: <IconFont type="icon-polyline" />,
|
||||
},
|
||||
{
|
||||
label: intl.formatMessage({
|
||||
id: 'banzone.circle',
|
||||
defaultMessage: 'Circle',
|
||||
}),
|
||||
key: '3',
|
||||
onClick: () => {
|
||||
history.push(SGW_ROUTE_BANZONES, {
|
||||
shape: 3,
|
||||
type: 'create',
|
||||
});
|
||||
},
|
||||
icon: <IconFont type="icon-circle" />,
|
||||
},
|
||||
];
|
||||
|
||||
const deleteMultipleBanzones = async (records: SgwModel.Banzone[]) => {
|
||||
const key = 'deleteMultiple';
|
||||
messageApi.open({
|
||||
key,
|
||||
type: 'loading',
|
||||
content: intl.formatMessage({
|
||||
id: 'common.deleting',
|
||||
defaultMessage: 'Deleting...',
|
||||
}),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
for (const record of records) {
|
||||
const groupID = groupFlattened.find(
|
||||
(m) => m.metadata.code === record.province_code,
|
||||
)?.id;
|
||||
|
||||
await apiRemoveBanzone(record.id || '', groupID || '');
|
||||
}
|
||||
messageApi.open({
|
||||
key,
|
||||
type: 'success',
|
||||
content: `Đã xoá thành công ${records.length} khu vực`,
|
||||
duration: 2,
|
||||
});
|
||||
tableRef.current?.reload();
|
||||
} catch (error) {
|
||||
console.error('Error deleting area:', error);
|
||||
messageApi.open({
|
||||
key,
|
||||
type: 'error',
|
||||
content: intl.formatMessage({
|
||||
id: 'banzone.notify.fail',
|
||||
defaultMessage: 'Delete zone failed!',
|
||||
}),
|
||||
duration: 2,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const SGWArea: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h1>Khu vực (SGW Manager)</h1>
|
||||
</div>
|
||||
<>
|
||||
{contextHolder}
|
||||
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
|
||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 4, lg: 4, xl: 4 }}>
|
||||
<TreeGroup
|
||||
multiple
|
||||
onSelected={(value) => {
|
||||
// Convert group IDs to province codes string
|
||||
const selectedIds = Array.isArray(value)
|
||||
? value
|
||||
: value
|
||||
? [value]
|
||||
: [];
|
||||
const provinceCodes =
|
||||
selectedIds.length > 0
|
||||
? selectedIds
|
||||
.reduce((codes: string[], id) => {
|
||||
const group = groupFlattened.find((g) => g.id === id);
|
||||
if (group?.metadata?.code) {
|
||||
codes.push(group.metadata.code);
|
||||
}
|
||||
return codes;
|
||||
}, [])
|
||||
.join(',')
|
||||
: '';
|
||||
|
||||
setGroupCheckedKeys(provinceCodes);
|
||||
tableRef.current?.reload();
|
||||
}}
|
||||
/>
|
||||
</ProCard>
|
||||
<ProCard colSpan={{ xs: 24, sm: 24, md: 20, lg: 20, xl: 20 }}>
|
||||
<ProTable<SgwModel.Banzone>
|
||||
tableLayout="fixed"
|
||||
scroll={{ x: 1000 }}
|
||||
actionRef={tableRef}
|
||||
columns={columns}
|
||||
pagination={{
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: ['5', '10', '15', '20'],
|
||||
showTotal: (total, range) =>
|
||||
`${range[0]}-${range[1]}
|
||||
${intl.formatMessage({
|
||||
id: 'common.of',
|
||||
defaultMessage: 'of',
|
||||
})}
|
||||
${total} ${intl.formatMessage({
|
||||
id: 'banzones.title',
|
||||
defaultMessage: 'zones',
|
||||
})}`,
|
||||
}}
|
||||
request={async (params) => {
|
||||
const { current, pageSize, name, type, enabled } = params;
|
||||
|
||||
// Nếu chưa có groups, đợi
|
||||
if (!groups || groups.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
|
||||
|
||||
const groupFalttened = flattenGroupNodes(groups || []);
|
||||
const groupId =
|
||||
groupCheckedKeys ||
|
||||
groupFalttened
|
||||
.map((group) => group.metadata.code)
|
||||
.filter(Boolean)
|
||||
.join(',') + ',';
|
||||
|
||||
if (!groupId || groupId === ',') {
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const body: SgwModel.SearchZonePaginationBody = {
|
||||
name: name || '',
|
||||
order: 'name',
|
||||
dir: 'asc',
|
||||
limit: pageSize,
|
||||
offset: offset,
|
||||
metadata: {
|
||||
province_code: groupId,
|
||||
...(type ? { type: Number(type) } : {}), // nếu có type thì thêm vào
|
||||
...(enabled !== undefined ? { enabled } : {}),
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await apiGetAllBanzones(body);
|
||||
return {
|
||||
success: true,
|
||||
data: resp?.banzones || [],
|
||||
total: resp?.total || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Query banzones failed:', error);
|
||||
return {
|
||||
success: true,
|
||||
data: [],
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
}}
|
||||
rowKey="id"
|
||||
options={{
|
||||
search: false,
|
||||
setting: false,
|
||||
density: false,
|
||||
reload: true,
|
||||
}}
|
||||
search={{
|
||||
layout: 'vertical',
|
||||
defaultCollapsed: false,
|
||||
}}
|
||||
dateFormatter="string"
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedRowsState?.map((row) => row?.id ?? ''),
|
||||
onChange: (_, selectedRows) => {
|
||||
setSelectedRows(selectedRows);
|
||||
},
|
||||
}}
|
||||
tableAlertRender={({ selectedRowKeys }) => (
|
||||
<div>Đã chọn {selectedRowKeys.length} mục</div>
|
||||
)}
|
||||
tableAlertOptionRender={({ selectedRows, onCleanSelected }) => {
|
||||
return (
|
||||
<Flex gap={5}>
|
||||
<Popconfirm
|
||||
title={intl.formatMessage({
|
||||
id: 'common.notification',
|
||||
defaultMessage: 'Thông báo',
|
||||
})}
|
||||
description={`Bạn muốn xoá hết ${selectedRows.length} khu vực này?`}
|
||||
onConfirm={() => {
|
||||
deleteMultipleBanzones(selectedRows);
|
||||
}}
|
||||
okText={intl.formatMessage({
|
||||
id: 'common.sure',
|
||||
defaultMessage: 'Chắc chắn',
|
||||
})}
|
||||
cancelText={intl.formatMessage({
|
||||
id: 'common.no',
|
||||
defaultMessage: 'Không',
|
||||
})}
|
||||
>
|
||||
<Button type="primary" danger>
|
||||
{intl.formatMessage({
|
||||
id: 'common.delete',
|
||||
defaultMessage: 'Xóa',
|
||||
})}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
|
||||
<Button color="cyan" variant="text" onClick={onCleanSelected}>
|
||||
{intl.formatMessage({
|
||||
id: 'common.cancel',
|
||||
defaultMessage: 'Bỏ chọn',
|
||||
})}
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}}
|
||||
toolBarRender={() => [
|
||||
<Dropdown
|
||||
menu={{ items }}
|
||||
trigger={['click']}
|
||||
key="toolbar-dropdown"
|
||||
>
|
||||
<Button type="primary">
|
||||
<Space>
|
||||
{intl.formatMessage({
|
||||
id: 'banzones.create',
|
||||
defaultMessage: 'Tạo khu vực',
|
||||
})}
|
||||
<DownOutlined />
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>,
|
||||
]}
|
||||
/>
|
||||
</ProCard>
|
||||
</ProCard>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SGWArea;
|
||||
export default BanZoneList;
|
||||
|
||||
Reference in New Issue
Block a user