feat(sgw): Implement Create or Update Banzone functionality with map integration

This commit is contained in:
Lê Tuấn Anh
2026-01-27 12:17:11 +07:00
parent c9aeca0ed9
commit a11e2c2991
46 changed files with 4660 additions and 39 deletions

View File

@@ -0,0 +1,317 @@
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
import { ModalForm } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, DatePicker, Flex, Form, Input, Select, Space } from 'antd';
import { FormListFieldData } from 'antd/lib';
import dayjs from 'dayjs';
import { useEffect } from 'react';
import { AreaCondition, ZoneFormField } from '../type';
const { RangePicker } = DatePicker;
// Transform form values to match AreaCondition type
const transformToAreaCondition = (values: any[]): AreaCondition[] => {
return values.map((item) => {
const { type } = item;
switch (type) {
case 'month_range': {
// RangePicker for month returns [Dayjs, Dayjs]
const [from, to] = item.from ?? [];
return {
type: 'month_range',
from: from?.month() ?? 0, // 0-11
to: to?.month() ?? 0,
};
}
case 'date_range': {
// RangePicker for date returns [Dayjs, Dayjs]
const [from, to] = item.from ?? [];
return {
type: 'date_range',
from: from?.format('YYYY-MM-DD') ?? '',
to: to?.format('YYYY-MM-DD') ?? '',
};
}
case 'length_limit': {
return {
type: 'length_limit',
min: Number(item.min) ?? 0,
max: Number(item.max) ?? 0,
};
}
default:
return item;
}
});
};
// Transform AreaCondition to form values
const transformToFormValues = (conditions?: AreaCondition[]): any[] => {
if (!conditions || conditions.length === 0) return [{}];
return conditions.map((condition) => {
switch (condition.type) {
case 'month_range': {
return {
type: 'month_range',
from: [dayjs().month(condition.from), dayjs().month(condition.to)],
};
}
case 'date_range': {
return {
type: 'date_range',
from: [dayjs(condition.from), dayjs(condition.to)],
};
}
case 'length_limit': {
return condition;
}
default:
return {};
}
});
};
const ConditionRow = ({
field,
remove,
}: {
field: FormListFieldData;
remove: (name: number) => void;
}) => {
const selectedType = Form.useWatch([
ZoneFormField.AreaConditions,
field.name,
'type',
]);
const intl = useIntl();
return (
<Flex gap="middle" style={{ marginBottom: 16 }}>
<Space align="baseline">
<Form.Item
name={[field.name, 'type']}
label={intl.formatMessage({
id: 'banzone.category.name',
defaultMessage: 'Danh mục',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.category.not_empty',
defaultMessage: 'Category cannot be empty!',
}),
},
]}
>
<Select
placeholder={intl.formatMessage({
id: 'banzone.category.add',
defaultMessage: 'Add category',
})}
>
<Select.Option value="month_range">
<FormattedMessage
id="banzone.condition.yearly_select"
defaultMessage="Yearly"
/>
</Select.Option>
<Select.Option value="date_range">
<FormattedMessage
id="banzone.condition.specific_time_select"
defaultMessage="Specific date"
/>
</Select.Option>
<Select.Option value="length_limit">
<FormattedMessage
id="banzone.condition.length_limit"
defaultMessage="Length limit"
/>
</Select.Option>
</Select>
</Form.Item>
{selectedType !== undefined &&
(selectedType === 'length_limit' ? (
<Form.Item
label={intl.formatMessage({
id: 'banzone.condition.length_limit',
defaultMessage: 'Length limit',
})}
required
>
<Space>
<Form.Item
name={[field.name, 'min']}
noStyle
rules={[
{
required: true,
message: intl.formatMessage({
id: 'common.not_empty',
defaultMessage: 'Cannot be empty!',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'banzone.condition.length_limit_min',
defaultMessage: 'Minimum',
})}
type="number"
/>
</Form.Item>
<Form.Item
name={[field.name, 'max']}
noStyle
rules={[
{
required: true,
message: intl.formatMessage({
id: 'common.not_empty',
defaultMessage: 'Cannot be empty!',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'banzone.condition.length_limit_max',
defaultMessage: 'Maximum',
})}
type="number"
/>
</Form.Item>
</Space>
</Form.Item>
) : (
<Form.Item
name={[field.name, 'from']}
label={intl.formatMessage({
id: 'banzone.condition.ban_time',
defaultMessage: 'Time',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'common.not_empty',
defaultMessage: 'Cannot be empty!',
}),
},
]}
>
<RangePicker
picker={selectedType === 'month_range' ? 'month' : 'date'}
format={
selectedType === 'month_range' ? 'MM/YYYY' : 'DD/MM/YYYY'
}
/>
</Form.Item>
))}
<MinusCircleOutlined onClick={() => remove(field.name)} />
</Space>
</Flex>
);
};
interface AddConditionFormProps {
initialData?: AreaCondition[];
isVisible: boolean;
setVisible: (visible: boolean) => void;
onFinish?: (values: AreaCondition[]) => void;
}
const AddConditionForm = ({
initialData,
isVisible,
setVisible,
onFinish,
}: AddConditionFormProps) => {
const [form] = Form.useForm();
useEffect(() => {
if (isVisible) {
form.resetFields();
if (initialData && initialData.length > 0) {
form.setFieldsValue({ conditions: transformToFormValues(initialData) });
} else {
form.setFieldsValue({ conditions: [{}] });
}
}
}, [isVisible, initialData]);
const handleSubmit = async () => {
try {
const values = await form.validateFields();
const transformedConditions = transformToAreaCondition(values.conditions);
onFinish?.(transformedConditions);
setVisible(false);
form.resetFields();
} catch (err) {
console.error('Validation failed', err);
}
};
return (
<ModalForm
form={form}
open={isVisible}
onOpenChange={(open) => {
if (!open) form.resetFields();
setVisible(open);
}}
title={
<FormattedMessage
id="banzone.category.add"
defaultMessage="Add category"
/>
}
width="600px"
submitter={{
searchConfig: { submitText: 'Lưu' },
render: (_, doms) => (
<Flex justify="center" gap={16}>
{doms}
</Flex>
),
}}
onFinish={handleSubmit}
>
<Form.List name={ZoneFormField.AreaConditions}>
{(fields, { add, remove }) => (
<>
{fields.map((field) => (
<ConditionRow
key={field.key}
field={field}
remove={remove}
// isFirst={index === 0}
/>
))}
<Form.Item>
<Flex justify="center">
<Button
type="dashed"
onClick={() => add()}
icon={<PlusOutlined />}
style={{ width: 300 }}
>
<FormattedMessage
id="banzone.category.add"
defaultMessage="Add category"
/>
</Button>
</Flex>
</Form.Item>
</>
)}
</Form.List>
</ModalForm>
);
};
export default AddConditionForm;

View File

@@ -0,0 +1,334 @@
import { getCircleRadius } from '@/utils/slave/sgw/geomUtils';
import { PlusOutlined } from '@ant-design/icons';
import {
ProFormDigit,
ProFormInstance,
ProFormItem,
ProFormText,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, Col, Flex, Form, Input, Row, Tag, Tooltip } from 'antd';
import { MutableRefObject, useMemo, useState } from 'react';
import {
CircleGeometry,
GeometryType,
PolygonGeometry,
tagPlusStyle,
validateGeometry,
ZoneFormData,
ZoneFormField,
} from '../type';
import PolygonModal from './PolygonModal';
type GeometryFormProps = {
shape?: number;
form: MutableRefObject<ProFormInstance<ZoneFormData> | null | undefined>;
zoneData?: SgwModel.Geom;
};
const GeometryForm = ({ shape, form }: GeometryFormProps) => {
const [isPolygonModalOpen, setIsPolygonModalOpen] = useState<boolean>(false);
const [indexTag, setIndexTag] = useState<number>(-1);
const intl = useIntl();
const polygonGeometry =
(Form.useWatch(
ZoneFormField.PolygonGeometry,
form.current || undefined,
) as PolygonGeometry[]) || [];
// Circle area calculation (subscribe to Radius so it updates)
const area = Form.useWatch(
[ZoneFormField.CircleData, 'area'],
form.current || undefined,
) as number | undefined;
const radiusArea = useMemo(() => {
if (shape === GeometryType.CIRCLE) {
if (area && area > 0) {
return getCircleRadius(area);
}
}
return 0;
}, [shape, area]);
const handleRemovePolygon = (index: number) => {
const newPolygons = polygonGeometry.filter(
(_, i) => i !== index,
) as PolygonGeometry[];
form.current?.setFieldsValue({
[ZoneFormField.PolygonGeometry]: newPolygons,
});
};
const handleEditPolygon = (index: number) => {
setIsPolygonModalOpen(true);
setIndexTag(index);
};
// Format coordinates for display
const formatCoords = (coords: number[][]) => {
return coords.map((c) => `[${c[0]}, ${c[1]}]`).join(', ');
};
switch (shape) {
case GeometryType.POLYGON:
return (
<>
<ProFormItem
name={ZoneFormField.PolygonGeometry}
label={intl.formatMessage({
id: 'banzone.geometry.coordinates',
defaultMessage: 'Tọa độ',
})}
required
>
<Flex gap="10px 4px" wrap>
{polygonGeometry.map((polygon, index) => (
<Tooltip
key={index}
title={formatCoords(polygon.geometry) || ''}
>
<Tag
closable
onClose={(e) => {
e.preventDefault();
handleRemovePolygon(index);
}}
onClick={() => handleEditPolygon(index)}
color="blue"
style={{ cursor: 'pointer' }}
>
{intl.formatMessage({
id: 'banzones.title',
defaultMessage: 'Khu vực',
})}{' '}
{index + 1}
</Tag>
</Tooltip>
))}
<Button
style={tagPlusStyle}
icon={<PlusOutlined />}
onClick={() => {
setIndexTag(-1);
setIsPolygonModalOpen(true);
}}
>
<FormattedMessage
id="banzone.geometry.add_zone"
defaultMessage="Thêm vùng"
/>
</Button>
</Flex>
</ProFormItem>
<PolygonModal
isVisible={isPolygonModalOpen}
setVisible={setIsPolygonModalOpen}
initialData={polygonGeometry}
index={indexTag}
handleSubmit={async (values) => {
form.current?.setFieldsValue({
[ZoneFormField.PolygonGeometry]: values,
});
setIndexTag(-1);
setIsPolygonModalOpen(false);
}}
/>
</>
);
case GeometryType.LINESTRING:
return (
<ProFormItem
required
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.geometry.coordinates.not_empty',
defaultMessage: 'Tọa độ không được để trống!',
}),
},
{
validator: (_: any, value: string) => {
return validateGeometry(value);
},
},
]}
name={ZoneFormField.PolylineData}
label={intl.formatMessage({
id: 'banzone.geometry.coordinates',
defaultMessage: 'Tọa độ',
})}
tooltip={intl.formatMessage({
id: 'banzone.geometry.coordinates.tooltip',
defaultMessage:
'Danh sách các toạ độ theo định dạng: [[longitude,latitude],[longitude,latitude],...]',
})}
>
<Input.TextArea
autoSize={{ minRows: 5, maxRows: 10 }}
placeholder={intl.formatMessage({
id: 'banzone.geometry.coordinates.placeholder',
defaultMessage:
'Ví dụ: [[109.5, 12.3], [109.6, 12.4], [109.7, 12.5]]',
})}
/>
</ProFormItem>
);
case GeometryType.CIRCLE:
return (
<>
<Row gutter={16}>
<Col span={12}>
<ProFormText
name={[ZoneFormField.CircleData, 'center', 0]}
label={intl.formatMessage({
id: 'banzone.geometry.longitude',
defaultMessage: 'Kinh độ',
})}
placeholder={intl.formatMessage({
id: 'banzone.geometry.longitude.placeholder',
defaultMessage: 'Nhập kinh độ',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.geometry.longitude.required',
defaultMessage: 'Kinh độ không được để trống!',
}),
},
]}
fieldProps={{
type: 'number',
step: '0.000001',
onChange: (e) => {
const lng = parseFloat(e.target.value);
const currentCircle = (form.current?.getFieldValue(
ZoneFormField.CircleData,
) as CircleGeometry) || { center: [0, 0], area: 0 };
form.current?.setFieldsValue({
[ZoneFormField.CircleData]: {
...currentCircle,
center: [lng, currentCircle.center?.[1] || 0],
},
});
},
}}
/>
</Col>
<Col span={12}>
<ProFormText
name={[ZoneFormField.CircleData, 'center', 1]}
label={intl.formatMessage({
id: 'banzone.geometry.latitude',
defaultMessage: 'Vĩ độ',
})}
placeholder={intl.formatMessage({
id: 'banzone.geometry.latitude.placeholder',
defaultMessage: 'Nhập vĩ độ',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.geometry.latitude.required',
defaultMessage: 'Vĩ độ không được để trống!',
}),
},
]}
fieldProps={{
type: 'number',
step: '0.000001',
onChange: (e) => {
const lat = parseFloat(e.target.value);
const currentCircle = (form.current?.getFieldValue(
ZoneFormField.CircleData,
) as CircleGeometry) || { center: [0, 0], radius: 0 };
form.current?.setFieldsValue({
[ZoneFormField.CircleData]: {
...currentCircle,
center: [currentCircle.center?.[0] || 0, lat],
},
});
},
}}
/>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<ProFormDigit
name={[ZoneFormField.CircleData, 'area']}
label={intl.formatMessage({
id: 'banzone.geometry.area',
defaultMessage: 'Diện tích (Hecta)',
})}
placeholder={intl.formatMessage({
id: 'banzone.geometry.area.placeholder',
defaultMessage: 'Nhập diện tích',
})}
min={1}
fieldProps={{
precision: 0,
onChange: (value) => {
const currentCircle = (form.current?.getFieldValue(
ZoneFormField.CircleData,
) as CircleGeometry) || { center: [0, 0], radius: 0 };
form.current?.setFieldsValue({
[ZoneFormField.CircleData]: {
center: currentCircle.center || [0, 0],
area: value || 0,
},
});
},
}}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.geometry.area.required',
defaultMessage: 'Diện tích không được để trống!',
}),
},
]}
/>
</Col>
<Col span={12}>
<ProFormItem
name={[ZoneFormField.CircleData, 'radius']}
label={intl.formatMessage({
id: 'banzone.geometry.radius',
defaultMessage: 'Bán kính (m)',
})}
>
<Input
disabled
readOnly
value={
radiusArea > 0
? `${radiusArea} ${intl.formatMessage({
id: 'banzone.geometry.metrics',
defaultMessage: 'mét',
})}`
: ''
}
addonAfter={intl.formatMessage({
id: 'banzone.geometry.auto_calculate',
defaultMessage: 'Tự động tính',
})}
/>
</ProFormItem>
</Col>
</Row>
</>
);
default:
return <div>Vui lòng chọn loại hình học đ nhập toạ đ.</div>;
}
};
export default GeometryForm;

View File

@@ -0,0 +1,175 @@
import {
ModalForm,
ProFormItem,
ProFormList,
} from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { Flex, Input, theme } from 'antd';
import { useEffect, useMemo, useRef } from 'react';
import { PolygonGeometry, validateGeometry } from '../type';
interface PolygonModalProps {
initialData?: PolygonGeometry[];
index?: number;
isVisible: boolean;
setVisible: (visible: boolean) => void;
handleSubmit: (values: PolygonGeometry[]) => Promise<void>;
}
const PolygonModal = ({
isVisible,
setVisible,
handleSubmit,
initialData,
index,
}: PolygonModalProps) => {
const formRef = useRef<any>();
const { token } = theme.useToken();
const intl = useIntl();
// Counter to track item index during render
let itemIndex = 0;
// Convert initialData to form format
const initialValues = useMemo(() => {
if (!initialData || initialData.length === 0) {
return { conditions: [] };
}
return {
conditions: initialData.map((item) => ({
geometry: JSON.stringify(item.geometry),
id: item.id || undefined,
})),
};
}, [initialData]);
useEffect(() => {
if (isVisible) {
formRef.current?.setFieldsValue(initialValues);
}
}, [isVisible, initialValues]);
// Handle form submit - convert back to AreaGeometry format
const handleFinish = async (values: any) => {
const conditions = values.conditions || [];
const result: PolygonGeometry[] = conditions.map((item: any) => ({
geometry: JSON.parse(item.geometry),
id: item.id,
}));
await handleSubmit(result);
};
return (
<>
{/* Global style for highlighting TextArea border */}
<style>{`
.highlighted-item .ant-input {
border-color: ${token.colorWarningActive} !important;
}
`}</style>
<ModalForm
formRef={formRef}
open={isVisible}
onOpenChange={(open) => {
if (!open) formRef.current?.resetFields();
setVisible(open);
}}
title={intl.formatMessage({
id: 'banzone.polygon_modal.title',
defaultMessage: 'Thêm toạ độ',
})}
width="40%"
initialValues={initialValues}
submitter={{
searchConfig: {
submitText: intl.formatMessage({
id: 'common.save',
defaultMessage: 'Lưu',
}),
},
render: (_, doms) => (
<Flex justify="center" gap={16}>
{doms}
</Flex>
),
}}
onFinish={handleFinish}
>
<ProFormList
required
name="conditions"
creatorButtonProps={{
position: 'bottom',
creatorButtonText: intl.formatMessage({
id: 'banzone.geometry.add_zone',
defaultMessage: 'Thêm toạ độ',
}),
}}
copyIconProps={false}
deleteIconProps={{
tooltipText: intl.formatMessage({
id: 'common.delete',
defaultMessage: 'Xoá',
}),
}}
itemRender={({ listDom, action }) => {
const currentIndex = itemIndex++;
const isHighlighted = index !== undefined && currentIndex === index;
return (
<div
className={isHighlighted ? 'highlighted-item' : ''}
data-item-index={currentIndex}
>
{listDom}
<Flex gap={8} justify="flex-end">
{action}
</Flex>
</div>
);
}}
>
{/* Hidden field for id - needed to preserve id when editing */}
<ProFormItem name="id" hidden>
<input type="hidden" />
</ProFormItem>
<ProFormItem
name="geometry"
required
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.geometry.coordinates.not_empty',
defaultMessage: 'Coordinates cannot be empty!',
}),
},
{
validator: (_rule: any, value: any) => {
return validateGeometry(value);
},
},
]}
label={intl.formatMessage({
id: 'banzone.geometry.coordinates',
defaultMessage: 'Coordinates',
})}
tooltip={intl.formatMessage({
id: 'banzone.geometry.coordinates.tooltip',
defaultMessage:
'Danh sách các toạ độ theo định dạng: [[longitude,latitude],[longitude,latitude],...]',
})}
>
<Input.TextArea
autoSize={{ minRows: 5, maxRows: 10 }}
placeholder={intl.formatMessage({
id: 'banzone.geometry.coordinates.placeholder',
defaultMessage:
'Example: [[109.5, 12.3], [109.6, 12.4], [109.7, 12.5]]',
})}
/>
</ProFormItem>
</ProFormList>
</ModalForm>
</>
);
};
export default PolygonModal;

View File

@@ -0,0 +1,273 @@
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
import { PlusOutlined } from '@ant-design/icons';
import {
ProForm,
ProFormInstance,
ProFormSelect,
ProFormSwitch,
ProFormText,
ProFormTextArea,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, Col, Flex, Form, Row, Tag, Tooltip } from 'antd';
import dayjs from 'dayjs';
import { MutableRefObject, useState } from 'react';
import { tagPlusStyle, ZoneFormData, ZoneFormField } from '../type';
import AddConditionForm from './AddConditionForm';
import GeometryForm from './GeometryForm';
interface ZoneFormProps {
shape?: number;
formRef: MutableRefObject<ProFormInstance<ZoneFormData> | null | undefined>;
}
const ZoneForm = ({ formRef, shape }: ZoneFormProps) => {
const intl = useIntl();
const [isConditionModalOpen, setIsConditionModalOpen] = useState(false);
const handleGroupSelect = (groupId: string | string[] | null) => {
formRef.current?.setFieldsValue({ [ZoneFormField.AreaId]: groupId });
};
const selectedGroupIds = Form.useWatch(
ZoneFormField.AreaId,
formRef.current || undefined,
) as string | string[] | null | undefined;
const conditionData = Form.useWatch(
ZoneFormField.AreaConditions,
formRef.current || undefined,
);
const handleConditionsClose = (indexToRemove: number) => {
formRef.current?.setFieldValue(
ZoneFormField.AreaConditions,
conditionData?.filter((_, index) => index !== indexToRemove),
);
};
return (
<>
<Row gutter={16}>
{/* Tên */}
<Col xs={24} md={12}>
<ProFormText
name={ZoneFormField.AreaName}
label={intl.formatMessage({
id: 'common.name',
defaultMessage: 'Tên',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.form.name.required.message',
defaultMessage: 'Tên khu vực không được để trống!',
}),
},
]}
required
/>
</Col>
{/* Loại */}
<Col xs={24} md={12}>
<ProFormSelect
name={ZoneFormField.AreaType}
label={intl.formatMessage({
id: 'common.type',
defaultMessage: 'Loại',
})}
required
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.form.area_type.required.message',
defaultMessage: 'Loại khu vực không được để trống',
}),
},
]}
placeholder={intl.formatMessage({
id: 'banzone.form.area_type.placeholder',
defaultMessage: 'Chọn loại khu vực',
})}
options={[
{
label: intl.formatMessage({
id: 'banzone.area.fishing_ban',
defaultMessage: 'Cấm đánh bắt',
}),
value: 1,
},
{
label: intl.formatMessage({
id: 'banzone.area.move_ban',
defaultMessage: 'Cấm di chuyển',
}),
value: 2,
},
{
label: intl.formatMessage({
id: 'banzone.area.safe',
defaultMessage: 'Vùng an toàn',
}),
value: 3,
},
]}
/>
</Col>
</Row>
<Row gutter={16}>
{/* Tỉnh */}
<Col xs={24} md={12}>
<ProForm.Item
name={ZoneFormField.AreaId}
label={intl.formatMessage({
id: 'common.province',
defaultMessage: 'Tỉnh',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'banzone.form.province.required.message',
defaultMessage: 'Tỉnh quản lý không được để trống!',
}),
},
]}
>
<TreeSelectedGroup
groupIds={selectedGroupIds ?? ''}
onSelected={handleGroupSelect}
/>
</ProForm.Item>
</Col>
{/* Có hiệu lực */}
<Col xs={24} md={12}>
<ProFormSwitch
name={ZoneFormField.AreaEnabled}
label={intl.formatMessage({
id: 'banzone.is_enable',
defaultMessage: 'Có hiệu lực',
})}
valuePropName="checked"
/>
</Col>
</Row>
<ProFormTextArea
name={ZoneFormField.AreaDescription}
label={intl.formatMessage({
id: 'common.description',
defaultMessage: 'Mô tả',
})}
placeholder={intl.formatMessage({
id: 'banzone.form.description.placeholder',
defaultMessage: 'Nhập mô tả khu vực',
})}
autoSize={{ minRows: 3, maxRows: 5 }}
/>
<Form.Item
name={ZoneFormField.AreaConditions}
label={intl.formatMessage({
id: 'banzone.condition',
defaultMessage: 'Điều kiện',
})}
>
<Flex gap="10px 4px" wrap>
{(conditionData || []).map((condition, index) => {
// console.log("Condition: ", condition);
let tootip = '';
let label = '';
const { type } = condition;
switch (type) {
case 'month_range': {
label = intl.formatMessage({
id: 'banzone.condition.yearly',
defaultMessage: 'Hàng năm',
});
const fromMonth = condition.from + 1;
const toMonth = condition.to + 1;
tootip = `Tháng từ ${fromMonth} đến tháng ${toMonth}`;
break;
}
case 'date_range': {
label = intl.formatMessage({
id: 'banzone.condition.specific_time',
defaultMessage: 'Thời gian cụ thể',
});
const fromDate = dayjs(condition.from).format('DD/MM/YYYY');
const toDate = dayjs(condition.to).format('DD/MM/YYYY');
tootip = `Từ ${fromDate} đến ${toDate}`;
break;
}
case 'length_limit':
label = intl.formatMessage({
id: 'banzone.condition.length_limit',
defaultMessage: 'Chiều dài cho phép',
});
tootip = `Chiều dài tàu từ ${condition.min} đến ${condition.max} mét`;
break;
default:
label = intl.formatMessage({
id: 'common.undefined',
defaultMessage: 'Không xác định',
});
}
return (
<Tooltip title={tootip} key={index}>
<Tag
closable
onClick={() => setIsConditionModalOpen(true)}
onClose={(e) => {
e.preventDefault();
handleConditionsClose(index);
}}
color={
type === 'month_range'
? 'blue'
: type === 'date_range'
? 'green'
: 'volcano'
}
>
{label}
</Tag>
</Tooltip>
);
})}
<Button
style={tagPlusStyle}
icon={<PlusOutlined />}
onClick={() => setIsConditionModalOpen(true)}
>
<FormattedMessage
id="banzone.condition.add"
defaultMessage="Thêm điều kiện"
/>
</Button>
</Flex>
</Form.Item>
<GeometryForm shape={shape} form={formRef} />
<AddConditionForm
isVisible={isConditionModalOpen}
setVisible={setIsConditionModalOpen}
initialData={conditionData}
onFinish={(newConditions) => {
try {
formRef.current?.setFieldValue(
ZoneFormField.AreaConditions,
newConditions,
);
} catch (e) {
console.error('Error setting form value:', e);
}
}}
/>
</>
);
};
export default ZoneForm;

View File

@@ -0,0 +1 @@
export { useMapGeometrySync } from './useMapGeometrySync';

View File

@@ -0,0 +1,304 @@
import { BaseMap, ZoneData } from '@/pages/Slave/SGW/Map/type';
import {
getAreaFromRadius,
getCircleRadius,
} from '@/utils/slave/sgw/geomUtils';
import { type ProFormInstance } from '@ant-design/pro-components';
import { Feature } from 'ol';
import { MutableRefObject, useEffect, useRef } from 'react';
import type { ZoneFormData } from '../type';
import { GeometryType, ZoneFormField } from '../type';
// Default zone data for drawing features
const DEFAULT_ZONE_DATA: ZoneData = { type: 'default' };
interface DrawnFeatureData {
type: string;
coordinates: any;
feature: any;
}
interface MapGeometrySyncOptions {
baseMap: MutableRefObject<BaseMap | null>;
dataLayerId: string;
form: MutableRefObject<ProFormInstance<ZoneFormData> | undefined>;
shape?: number;
enabled?: boolean;
}
/**
* Custom hook to sync geometry between Map and Form
* - Draw on Map -> Update Form
* - Modify on Map -> Update Form
* - Form changes -> Update Map (optional)
*/
export const useMapGeometrySync = (options: MapGeometrySyncOptions) => {
const { baseMap, dataLayerId, form, enabled = true } = options;
const isHandlingUpdateRef = useRef(false);
useEffect(() => {
if (!baseMap || !enabled) return;
// Handle feature drawn event
const handleFeatureDrawn = (data: DrawnFeatureData) => {
if (isHandlingUpdateRef.current) return;
isHandlingUpdateRef.current = true;
switch (data.type) {
case 'Polygon': {
const currentGeometry =
form.current?.getFieldValue(ZoneFormField.PolygonGeometry) || [];
const polygonId = `polygon_${Date.now()}`; // Tạo unique ID
data.feature.set('polygonId', polygonId); // Sửa: set trực tiếp key-value
const polygonCoords = data.coordinates;
form.current?.setFieldsValue({
[ZoneFormField.PolygonGeometry]: [
...currentGeometry,
{
geometry: polygonCoords[0],
id: polygonId,
},
],
});
break;
}
case 'LineString': {
baseMap.current?.clearFeatures(dataLayerId);
const lineCoords = data.coordinates;
form.current?.setFieldsValue({
[ZoneFormField.PolylineData]: JSON.stringify(lineCoords),
});
break;
}
case 'Circle': {
baseMap.current?.clearFeatures(dataLayerId);
const circleData = data.coordinates;
console.log('Circle Data: ', circleData);
form.current?.setFieldsValue({
[ZoneFormField.CircleData]: {
center: circleData.center,
area: getAreaFromRadius(circleData.radius),
},
});
break;
}
case 'Point':
// Point is stored as circle with small radius
// form?.setFieldsValue({
// [ZoneFormField.CircleData]: {
// center: data.coordinates,
// radius: 100, // Default radius for point
// },
// [ZoneFormField.Radius]: 100,
// });
break;
}
setTimeout(() => {
isHandlingUpdateRef.current = false;
}, 100);
};
// Handle feature modified event
const handleFeatureModified = (data: {
feature: any;
coordinates: any;
}) => {
if (isHandlingUpdateRef.current) return;
isHandlingUpdateRef.current = true;
const geometry = data.feature.getGeometry();
if (!geometry) {
isHandlingUpdateRef.current = false;
return;
}
const geomType = geometry.getType();
switch (geomType) {
case 'Polygon': {
const polygonId = data.feature.get('polygonId');
if (polygonId) {
const currentGeometry =
form.current?.getFieldValue(ZoneFormField.PolygonGeometry) || [];
const modifiedCoords = data.coordinates[0];
const updatedGeometry = currentGeometry.map((item: any) =>
item.id === polygonId
? { ...item, geometry: modifiedCoords }
: item,
);
form.current?.setFieldsValue({
[ZoneFormField.PolygonGeometry]: updatedGeometry,
});
}
break;
}
case 'LineString': {
form.current?.setFieldsValue({
[ZoneFormField.PolylineData]: JSON.stringify(data.coordinates),
});
break;
}
case 'Circle': {
form.current?.setFieldsValue({
[ZoneFormField.CircleData]: {
center: data.coordinates.center,
// radius: data.coordinates.radius,
area: getAreaFromRadius(data.coordinates.radius),
},
});
break;
}
}
setTimeout(() => {
isHandlingUpdateRef.current = false;
}, 100);
};
// Register event listeners
baseMap.current?.onFeatureDrawn(handleFeatureDrawn);
baseMap.current?.onFeatureModified(handleFeatureModified);
// Cleanup
return () => {
// Note: BaseMap doesn't have off method, so events will remain
// This is acceptable as the component unmounts
};
}, [baseMap, enabled]);
/**
* Update map display based on current form data
*/
const updateMapFromForm = (type: GeometryType) => {
if (!baseMap || !form) return;
const formCurrent = form.current;
let geometryData;
if (type === GeometryType.POLYGON) {
geometryData =
formCurrent?.getFieldValue(ZoneFormField.PolygonGeometry) || [];
} else if (type === GeometryType.LINESTRING) {
const polylineString =
formCurrent?.getFieldValue(ZoneFormField.PolylineData) || [];
geometryData = JSON.parse(polylineString);
} else if (type === GeometryType.CIRCLE) {
geometryData = formCurrent?.getFieldValue(ZoneFormField.CircleData)
? [formCurrent?.getFieldValue(ZoneFormField.CircleData)]
: [];
}
if (!geometryData || geometryData.length === 0) {
baseMap.current?.clearFeatures(dataLayerId);
return;
}
// Nếu đang xử lý event từ Map (vẽ/sửa), KHÔNG clear và vẽ lại
// vì Draw interaction đã add feature vào map sẵn rồi
if (isHandlingUpdateRef.current) {
return;
}
// Clear existing features trước khi vẽ lại (chỉ khi không đang handle map event)
baseMap.current?.clearFeatures(dataLayerId);
switch (type) {
case GeometryType.POLYGON:
{
const features: Feature[] = [];
geometryData.forEach((geom: any) => {
const feature = baseMap.current?.addPolygon(
dataLayerId,
[geom.geometry],
DEFAULT_ZONE_DATA,
geom.id,
);
features.push(feature!);
});
baseMap.current?.zoomToFeatures(features);
}
break;
case GeometryType.LINESTRING:
{
const feature = baseMap.current?.addPolyline(
dataLayerId,
geometryData,
DEFAULT_ZONE_DATA,
);
baseMap.current?.zoomToFeatures([feature!]);
}
break;
case GeometryType.CIRCLE:
{
const feature = baseMap.current?.addCircle(
dataLayerId,
geometryData[0].center,
getCircleRadius(geometryData[0].area),
DEFAULT_ZONE_DATA,
);
baseMap.current?.zoomToFeatures([feature!]);
}
break;
}
};
/**
* Clear all drawn features from map
*/
const clearMapFeatures = () => {
if (!baseMap) return;
baseMap.current?.clearFeatures(dataLayerId);
};
/**
* Draw existing geometry on map (for update mode)
*/
const drawExistingGeometry = (geometry: any) => {
if (!baseMap || !geometry) return;
clearMapFeatures();
if (geometry.geom_type === GeometryType.POLYGON && geometry.polygons) {
geometry.polygons.forEach((polygonCoords: number[][]) => {
baseMap.current?.addPolygon(
dataLayerId,
[polygonCoords],
DEFAULT_ZONE_DATA,
);
});
} else if (
geometry.geom_type === GeometryType.LINESTRING &&
geometry.coordinates
) {
baseMap.current?.addPolyline(
dataLayerId,
geometry.coordinates,
DEFAULT_ZONE_DATA,
);
} else if (
geometry.geom_type === GeometryType.CIRCLE &&
geometry.center &&
geometry.radius
) {
baseMap.current?.addCircle(
dataLayerId,
geometry.center,
geometry.radius,
DEFAULT_ZONE_DATA,
);
}
};
return {
updateMapFromForm,
clearMapFeatures,
drawExistingGeometry,
};
};
export default useMapGeometrySync;

View File

@@ -0,0 +1,520 @@
import { SGW_ROUTE_BANZONES_LIST } from '@/constants/slave/sgw/routes';
import VietNamMap, {
VietNamMapRef,
} from '@/pages/Slave/SGW/Map/components/VietNamMap';
import { BaseMap, DATA_LAYER } from '@/pages/Slave/SGW/Map/type';
import {
apiCreateBanzone,
apiGetZoneById,
apiUpdateBanzone,
} from '@/services/slave/sgw/ZoneController';
import {
getAreaFromRadius,
getCircleRadius,
} from '@/utils/slave/sgw/geomUtils';
import { DeleteOutlined, EditFilled, GatewayOutlined } from '@ant-design/icons';
import {
PageContainer,
ProCard,
ProForm,
ProFormInstance,
} from '@ant-design/pro-components';
import {
FormattedMessage,
history,
useIntl,
useLocation,
useModel,
useParams,
} from '@umijs/max';
import { Button, Flex, Form, Grid, message } from 'antd';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import { useCallback, useEffect, useRef, useState } from 'react';
import ZoneForm from './components/ZoneForm';
import { useMapGeometrySync } from './hooks';
import {
AreaCondition,
checkValidateGeometry,
DrawActionType,
formatLineStringWKT,
formatPolygonGeometryToWKT,
GeometryType,
parseLineStringWKT,
parseMultiPolygonWKT,
parsePointWKT,
PolygonGeometry,
ZoneFormData,
ZoneFormField,
ZoneLocationState,
} from './type';
const CreateOrUpdateBanzone = () => {
const location = useLocation() as { state: ZoneLocationState };
const shape = location.state?.shape;
const type = location.state?.type;
const { id: zoneId } = useParams();
const intl = useIntl();
const { useBreakpoint } = Grid;
const screens = useBreakpoint();
const formRef = useRef<ProFormInstance<ZoneFormData>>();
const vietNamMapRef = useRef<VietNamMapRef>(null);
const baseMap = useRef<BaseMap | null>(null);
const drawController = useRef<any>(null);
const [loading, setLoading] = useState(false);
const [formReady, setFormReady] = useState(false);
const { groupMap } = useModel('master.useGroups');
const [mapActions, setMapActions] = useState<DrawActionType>({
isDrawing: false,
isModifying: false,
});
const { clearMapFeatures, updateMapFromForm } = useMapGeometrySync({
baseMap: baseMap,
dataLayerId: DATA_LAYER,
form: formRef,
shape,
enabled: !!baseMap.current,
});
// Handler for back navigation
const handleBack = () => {
history.push(SGW_ROUTE_BANZONES_LIST);
formRef.current?.resetFields();
};
const handleMapReady = useCallback((baseMapInstance: BaseMap) => {
baseMap.current = baseMapInstance;
// Create a vector layer for dynamic features
const vectorDataLayer = new VectorLayer({
source: new VectorSource(),
});
vectorDataLayer.set('id', DATA_LAYER);
baseMapInstance.addLayer(vectorDataLayer);
baseMapInstance.setView([116.152685, 15.70581], 5);
// Initialize draw controller
drawController.current = baseMapInstance.DrawAndModifyFeature(DATA_LAYER);
}, []);
const handleEnableModify = () => {
if (drawController.current) {
setMapActions({
isDrawing: false,
isModifying: true,
});
drawController.current.removeInteractions();
drawController.current.enableModify();
}
};
const handleSubmit = async (values?: ZoneFormData) => {
if (!values) return;
// Validate required fields
if (!values[ZoneFormField.AreaName]) {
message.error(
intl.formatMessage({ id: 'banzone.form.name.error.required' }),
);
return;
}
if (!values[ZoneFormField.AreaType]) {
message.error(
intl.formatMessage({ id: 'banzone.form.name.area_type.required' }),
);
return;
}
if (!values[ZoneFormField.AreaId]) {
message.error(
intl.formatMessage({ id: 'banzone.form.name.province.required' }),
);
return;
}
const lineStringData = values[ZoneFormField.PolylineData];
const polygonData = values[ZoneFormField.PolygonGeometry];
const circleData = values[ZoneFormField.CircleData];
if (!lineStringData && !polygonData && !circleData) {
message.error(
intl.formatMessage({ id: 'banzone.form.name.geometry.required' }),
);
return;
}
let geometryJson = '';
// // Convert geometry to API format
switch (shape) {
case GeometryType.POLYGON: {
const polygonWKT: string = formatPolygonGeometryToWKT(
polygonData || [],
);
geometryJson = JSON.stringify({
geom_type: GeometryType.POLYGON,
geom_poly: polygonWKT,
geom_lines: '',
geom_point: '',
geom_radius: 0,
});
break;
}
case GeometryType.LINESTRING: {
const polylineData = JSON.parse(lineStringData || '[]');
const polylineWKT: string = formatLineStringWKT(polylineData);
geometryJson = JSON.stringify({
geom_type: GeometryType.LINESTRING,
geom_poly: '',
geom_lines: polylineWKT,
geom_point: '',
geom_radius: 0,
});
break;
}
case GeometryType.CIRCLE: {
// For circle, API expects radius in hecta, convert from meters
const radiusInMetter = getCircleRadius(circleData?.area || 0);
const pointWKT = `POINT(${circleData?.center[0] || 0} ${
circleData?.center[1] || 0
})`;
geometryJson = JSON.stringify({
geom_type: GeometryType.CIRCLE,
geom_poly: '',
geom_lines: '',
geom_point: pointWKT,
geom_radius: radiusInMetter,
});
break;
}
default:
message.error('Loại hình học không hợp lệ!');
return;
}
const groupId = values[ZoneFormField.AreaId];
const provinceCode = Array.isArray(groupId)
? groupId.map((id) => groupMap[id]?.code || '').join(',')
: groupMap[groupId as string]?.code || '';
// // Prepare request body
const requestBody: SgwModel.ZoneBodyRequest = {
name: values[ZoneFormField.AreaName],
type: values[ZoneFormField.AreaType],
group_id: groupId as string,
province_code: provinceCode,
enabled: values[ZoneFormField.AreaEnabled] ?? true,
description: values[ZoneFormField.AreaDescription],
conditions: values[ZoneFormField.AreaConditions] as SgwModel.Condition[],
geom: geometryJson,
};
console.log('Submit body:', requestBody);
try {
setLoading(true);
if (type === 'create') {
const key = 'create';
message.open({
key,
type: 'loading',
content: intl.formatMessage({ id: 'banzone.creating' }),
});
await apiCreateBanzone(requestBody);
message.open({
key,
type: 'success',
content: intl.formatMessage({ id: 'banzone.creating_success' }),
});
} else if (type === 'update' && zoneId) {
const key = 'update';
message.open({
key,
type: 'loading',
content: intl.formatMessage({ id: 'banzone.updating' }),
});
await apiUpdateBanzone(zoneId, requestBody);
message.open({
key,
type: 'success',
content: intl.formatMessage({ id: 'banzone.updating_success' }),
});
} else {
message.error(
type === 'update'
? intl.formatMessage({ id: 'banzone.updating_fail' })
: intl.formatMessage({ id: 'banzone.creating_fail' }),
);
return;
}
setTimeout(() => {
handleBack();
}, 1000);
} catch (error) {
console.error('Failed to save zone:', error);
message.error(intl.formatMessage({ id: 'banzone.fail.save' }));
} finally {
setLoading(false);
}
};
const handleOnClickDraw = () => {
drawController.current = baseMap.current?.DrawAndModifyFeature(DATA_LAYER);
setMapActions({
isDrawing: true,
isModifying: false,
});
drawController.current.removeInteractions();
switch (shape) {
case 1:
drawController.current.drawPolygon();
break;
case 2:
drawController.current.drawLineString();
break;
case 3:
drawController.current.drawCircle();
break;
case 4:
drawController.current.drawPoint();
break;
default:
break;
}
};
const polygonGeometry =
(Form.useWatch(
ZoneFormField.PolygonGeometry,
formReady && formRef.current ? formRef.current : undefined,
) as PolygonGeometry[]) || [];
const polylineData = Form.useWatch(
ZoneFormField.PolylineData,
formReady && formRef.current ? formRef.current : undefined,
);
const circleData = Form.useWatch(
ZoneFormField.CircleData,
formReady && formRef.current ? formRef.current : undefined,
);
useEffect(() => {
if (polygonGeometry && polygonGeometry.length > 0) {
updateMapFromForm(GeometryType.POLYGON);
}
}, [polygonGeometry, formRef]);
useEffect(() => {
if (polylineData && checkValidateGeometry(polylineData)) {
updateMapFromForm(GeometryType.LINESTRING);
}
}, [polylineData, formRef]);
useEffect(() => {
if (circleData) {
updateMapFromForm(GeometryType.CIRCLE);
}
}, [circleData, formRef]);
return (
<PageContainer
onBack={handleBack}
title={
type === 'create' ? (
<FormattedMessage id="banzones.create" />
) : (
<FormattedMessage id="banzones.update" />
)
}
style={{
padding: 10,
}}
>
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard colSpan={{ xs: 24, sm: 24, md: 10, lg: 10, xl: 10 }}>
<ProForm<ZoneFormData>
formRef={formRef}
layout="vertical"
onFinish={handleSubmit}
onInit={(_, form) => {
formRef.current = form;
setFormReady(true);
}}
initialValues={{
[ZoneFormField.AreaEnabled]: true,
}}
request={async () => {
if (type === 'update' && zoneId) {
try {
const zone: SgwModel.Banzone = await apiGetZoneById(zoneId);
if (!zone) return {} as ZoneFormData;
// Parse geometry (API may use `geom` or `geometry` field)
let parsedGeometry: SgwModel.Geom | undefined;
const geomRaw = (zone as any).geom ?? (zone as any).geometry;
if (geomRaw) {
try {
parsedGeometry =
typeof geomRaw === 'string'
? JSON.parse(geomRaw)
: geomRaw;
} catch (e) {
console.warn('Failed to parse geometry', e);
}
}
const groupId = Object.entries(groupMap).find(
([, value]) => value.code === zone.province_code,
)?.[0] as string | undefined;
// Build base form data
const formData: Partial<ZoneFormData> = {
[ZoneFormField.AreaName]: zone.name || '',
[ZoneFormField.AreaType]: zone.type ?? 1,
[ZoneFormField.AreaEnabled]: zone.enabled ?? true,
[ZoneFormField.AreaDescription]: zone.description || '',
[ZoneFormField.AreaId]: groupId || '',
[ZoneFormField.AreaConditions]:
zone.conditions as AreaCondition[],
};
// Map geometry to form fields depending on geometry type
if (parsedGeometry) {
switch (parsedGeometry.geom_type) {
case GeometryType.POLYGON: {
const polygons = parseMultiPolygonWKT(
parsedGeometry.geom_poly || '',
);
formData[ZoneFormField.PolygonGeometry] = polygons.map(
(polygon) => ({
geometry: polygon,
id: Math.random().toString(36).substring(2, 9),
}),
);
break;
}
case GeometryType.LINESTRING: {
const polyline = parseLineStringWKT(
parsedGeometry.geom_lines || '',
);
formData[ZoneFormField.PolylineData] =
JSON.stringify(polyline);
break;
}
case GeometryType.CIRCLE: {
const center = parsePointWKT(
parsedGeometry.geom_point || '',
);
formData[ZoneFormField.CircleData] = {
center: center || [0, 0],
radius: parsedGeometry.geom_radius || 0,
area: getAreaFromRadius(
parsedGeometry.geom_radius || 0,
),
};
break;
}
default:
break;
}
}
return formData as ZoneFormData;
} catch (error) {
console.error('Failed to load zone for request:', error);
return {} as ZoneFormData;
}
}
return {} as ZoneFormData;
}}
submitter={{
searchConfig: {
submitText:
type === 'create'
? intl.formatMessage({ id: 'banzone.create.button.title' })
: intl.formatMessage({ id: 'banzone.update.button.title' }),
},
submitButtonProps: {
loading: loading,
},
render: (_, dom) => {
return (
<Flex
gap={8}
justify="center"
style={{ marginTop: 24, marginBottom: 24 }}
>
{dom}
</Flex>
);
},
}}
>
<ZoneForm formRef={formRef} shape={shape} />
<Flex align="center" gap={8} justify="end">
<Button
onClick={handleOnClickDraw}
variant={`${mapActions.isDrawing ? 'solid' : 'dashed'}`}
color={`${mapActions.isDrawing ? 'green' : 'default'}`}
icon={<GatewayOutlined />}
>
<FormattedMessage
id="banzone.map_action.draw"
defaultMessage="Draw"
/>
</Button>
<Button
onClick={handleEnableModify}
variant={`${mapActions.isModifying ? 'solid' : 'dashed'}`}
color={`${mapActions.isModifying ? 'green' : 'default'}`}
icon={<EditFilled />}
>
<FormattedMessage
id="banzone.map_action.modify"
defaultMessage="Modify"
/>
</Button>
<Button
danger
type="primary"
icon={<DeleteOutlined />}
onClick={() => {
clearMapFeatures();
setMapActions({
isDrawing: false,
isModifying: false,
});
switch (shape) {
case GeometryType.POLYGON:
formRef.current?.setFieldValue(
ZoneFormField.PolygonGeometry,
[],
);
break;
case GeometryType.LINESTRING:
formRef.current?.setFieldValue(
ZoneFormField.PolylineData,
'',
);
break;
case GeometryType.CIRCLE:
formRef.current?.setFieldValue(
ZoneFormField.CircleData,
undefined,
);
break;
default:
break;
}
}}
></Button>
</Flex>
</ProForm>
</ProCard>
<ProCard ghost colSpan={{ xs: 24, sm: 24, md: 14, lg: 14, xl: 14 }}>
<VietNamMap
ref={vietNamMapRef}
style={{
maxWidth: '100%',
height: '100%',
width: '100%',
}}
onMapReady={handleMapReady}
/>
</ProCard>
</ProCard>
</PageContainer>
);
};
export default CreateOrUpdateBanzone;

View File

@@ -0,0 +1,216 @@
export enum ZoneFormField {
AreaName = 'name',
AreaType = 'type',
AreaId = 'id',
AreaEnabled = 'enabled',
AreaDescription = 'description',
AreaConditions = 'conditions',
AreaProvinceCode = 'province_code',
PolygonGeometry = 'geometry',
PolylineData = 'polyline_data',
CircleData = 'circle_data',
}
export interface ZoneFormData {
[ZoneFormField.AreaName]: string;
[ZoneFormField.AreaType]: number; // API returns number
[ZoneFormField.AreaId]: string | string[] | null;
[ZoneFormField.AreaEnabled]: boolean;
[ZoneFormField.AreaDescription]?: string;
[ZoneFormField.AreaConditions]?: AreaCondition[];
[ZoneFormField.AreaProvinceCode]: string | number; // API returns string
[ZoneFormField.PolygonGeometry]: PolygonGeometry[];
[ZoneFormField.PolylineData]?: string;
[ZoneFormField.CircleData]?: CircleGeometry;
}
type MonthRangeCondition = {
type: 'month_range';
from: number;
to: number;
};
type DateRangeCondition = {
type: 'date_range';
from: string;
to: string;
};
type LengthLimitCondition = {
type: 'length_limit';
min: number;
max: number;
};
export type AreaCondition =
| MonthRangeCondition
| DateRangeCondition
| LengthLimitCondition;
export interface ZoneLocationState {
shape?: number;
type: 'create' | 'update';
}
export const tagPlusStyle = {
height: 22,
// background: "blue",
borderStyle: 'dashed',
};
export type PolygonGeometry = {
geometry: number[][];
id?: string;
};
export type CircleGeometry = {
center: [number, number];
radius: number;
area?: number;
};
// Geometry type mapping with API
export enum GeometryType {
POLYGON = 1,
LINESTRING = 2,
CIRCLE = 3,
}
export interface DrawActionType {
isDrawing: boolean;
isModifying: boolean;
}
// Parse MULTIPOINT/WKT for polygon
export const parseMultiPolygonWKT = (wktString: string): number[][][] => {
if (
!wktString ||
typeof wktString !== 'string' ||
!wktString.startsWith('MULTIPOLYGON')
) {
return [];
}
const matched = wktString.match(/MULTIPOLYGON\s*\(\(\((.*)\)\)\)/);
if (!matched) return [];
const polygons = matched[1]
.split(')),((') // chia các polygon
.map((polygonStr) =>
polygonStr
.trim()
.split(',')
.map((coordStr) => {
const [x, y] = coordStr.trim().split(' ').map(Number);
return [x, y];
}),
);
return polygons;
};
// // Parse LINESTRING WKT
export const parseLineStringWKT = (wkt: string): number[][] => {
if (!wkt || !wkt.startsWith('LINESTRING')) return [];
const match = wkt.match(/LINESTRING\s*\((.*)\)/);
if (!match) return [];
return match[1].split(',').map((coordStr) => {
const [x, y] = coordStr.trim().split(' ').map(Number);
return [x, y]; // [lng, lat]
});
};
// // Parse POINT WKT
export const parsePointWKT = (wkt: string): [number, number] | null => {
if (!wkt || !wkt.startsWith('POINT')) return null;
const match = wkt.match(/POINT\s*\(([-\d.]+)\s+([-\d.]+)\)/);
if (!match) return null;
return [parseFloat(match[1]), parseFloat(match[2])]; // [lng, lat]
};
// Format coordinates array to WKT LINESTRING
export const formatLineStringWKT = (coordinates: number[][]): string => {
const coordStr = coordinates.map((c) => `${c[0]} ${c[1]}`).join(',');
return `LINESTRING(${coordStr})`;
};
// Format AreaGeometry[] to WKT MULTIPOLYGON string
export const formatPolygonGeometryToWKT = (
geometries: PolygonGeometry[],
): string => {
const polygons = geometries.map((g) => g.geometry as number[][]);
if (polygons.length === 0) return '';
const polygonStrs = polygons.map((polygon) => {
const coordStr = polygon.map((c) => `${c[0]} ${c[1]}`).join(',');
return `(${coordStr})`;
});
return `MULTIPOLYGON((${polygonStrs.join('),(')}))`;
};
export const validateGeometry = (value: any) => {
if (!value || typeof value !== 'string') {
return Promise.reject('Dữ liệu không hợp lệ!');
}
try {
const text = value.trim();
const formattedText = text.startsWith('[[') ? text : `[${text}]`;
const data = JSON.parse(formattedText);
if (!Array.isArray(data)) {
return Promise.reject('Dữ liệu không phải mảng!');
}
for (const item of data) {
if (
!Array.isArray(item) ||
item.length !== 2 ||
typeof item[0] !== 'number' ||
typeof item[1] !== 'number'
) {
return Promise.reject(
'Mỗi dòng phải là [longitude, latitude] với số thực!',
);
}
}
return Promise.resolve();
} catch (e) {
return Promise.reject('Định dạng JSON không hợp lệ!');
}
};
export const checkValidateGeometry = (value: string) => {
if (!value || typeof value !== 'string') {
return false;
}
try {
const text = value.trim();
const formattedText = text.startsWith('[[') ? text : `[${text}]`;
const data = JSON.parse(formattedText);
if (!Array.isArray(data)) {
return false;
}
for (const item of data) {
if (
!Array.isArray(item) ||
item.length !== 2 ||
typeof item[0] !== 'number' ||
typeof item[1] !== 'number'
) {
return false;
}
}
return true;
} catch (e) {
return false;
}
};

View File

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

View File

@@ -0,0 +1,399 @@
import { HTTPSTATUS } from '@/constants';
import {
apiCreateFishSpecies,
apiUpdateFishSpecies,
} from '@/services/slave/sgw/FishController';
import {
apiDeletePhoto,
apiUploadPhoto,
} from '@/services/slave/sgw/PhotoController';
import {
ModalForm,
ProForm,
ProFormInstance,
ProFormSelect,
ProFormText,
ProFormTextArea,
ProFormUploadButton,
} from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { MessageInstance } from 'antd/es/message/interface';
import type { UploadFile } from 'antd/es/upload/interface';
import { useRef, useState } from 'react';
export type AddOrUpdateFishProps = {
type: 'create' | 'update';
fish?: SgwModel.Fish;
isOpen?: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
message: MessageInstance;
onReload?: (isSuccess: boolean) => void;
};
const AddOrUpdateFish = ({
type,
fish,
isOpen,
setIsOpen,
message,
onReload,
}: AddOrUpdateFishProps) => {
const formRef = useRef<ProFormInstance<SgwModel.Fish>>();
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [originalFileList, setOriginalFileList] = useState<UploadFile[]>([]);
const intl = useIntl();
// Check ảnh có thay đổi so với ban đầu không
const hasImageChanged = () => {
const currentHasImage = fileList.length > 0;
const originalHasImage = originalFileList.length > 0;
// Nếu số lượng ảnh khác nhau → có thay đổi
if (currentHasImage !== originalHasImage) {
return true;
}
// Nếu cả 2 đều rỗng → không thay đổi
if (!currentHasImage) {
return false;
}
// Nếu có ảnh, check xem file có phải là file mới upload không
// (file gốc có uid = '-1', file mới upload có uid khác)
const currentFile = fileList[0];
const isOriginalImage =
currentFile.uid === '-1' && currentFile.status === 'done';
return !isOriginalImage;
};
return (
<ModalForm<SgwModel.Fish>
key={fish?.id || 'new'}
open={isOpen}
formRef={formRef}
title={
type === 'create'
? intl.formatMessage({
id: 'fish.create.title',
defaultMessage: 'Thêm cá mới',
})
: intl.formatMessage({
id: 'fish.update.title',
defaultMessage: 'Cập nhật cá',
})
}
onOpenChange={setIsOpen}
layout="vertical"
modalProps={{
destroyOnHidden: true,
}}
request={async () => {
if (type === 'update' && fish) {
return fish;
}
setFileList([]);
setOriginalFileList([]);
return {};
}}
onFinish={async (values) => {
// 1. Cập nhật thông tin cá
if (type === 'create') {
// TODO: Gọi API tạo cá mới
// const result = await apiCreateFish(values);
console.log('Create fish:', values);
try {
const resp = await apiCreateFishSpecies(values);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success(
intl.formatMessage({
id: 'fish.create.success',
defaultMessage: 'Tạo cá thành công',
}),
);
onReload?.(true);
const id = resp.data.name_ids![0];
if (fileList.length > 0 && fileList[0].originFileObj && id) {
// TODO: Sau khi có result.id từ API create
// await apiUploadPhoto('fish', result.id, fileList[0].originFileObj);
console.log('Upload photo for new fish');
try {
const resp = await apiUploadPhoto(
'fish',
id.toString(),
fileList[0].originFileObj,
);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success(
intl.formatMessage({
id: 'fish.create.image.success',
defaultMessage: 'Thêm ảnh cá thành công',
}),
);
onReload?.(true);
} else {
throw new Error('Thêm ảnh thất bại');
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'fish.create.image.fail',
defaultMessage: 'Thêm ảnh cá thất bại',
}),
);
}
}
} else {
throw new Error('Tạo cá thất bại');
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'fish.create.fail',
defaultMessage: 'Tạo cá thất bại',
}),
);
}
// 2. Upload ảnh (nếu có chọn ảnh)
onReload?.(true);
} else {
// TODO: Gọi API cập nhật cá
// await apiUpdateFish(fish!.id!, values);
console.log('Update fish:', fish?.id, values);
// Check nếu dữ liệu có thay đổi so với ban đầu
const hasDataChanged =
fish!.name !== values.name ||
fish!.scientific_name !== values.scientific_name ||
fish!.group_name !== values.group_name ||
fish!.rarity_level !== values.rarity_level ||
fish!.note !== values.note;
if (hasDataChanged) {
try {
const body = { ...values, id: fish!.id! };
const resp = await apiUpdateFishSpecies(body);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success(
intl.formatMessage({
id: 'fish.update.success',
defaultMessage: 'Cập nhật cá thành công',
}),
);
onReload?.(true);
} else {
throw new Error('Cập nhật cá thất bại');
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'fish.update.fail',
defaultMessage: 'Cập nhật cá thất bại',
}),
);
return true;
}
} else {
console.log('Dữ liệu không thay đổi, bỏ qua API update');
}
// 2. Upload ảnh (chỉ khi ảnh có thay đổi)
if (hasImageChanged()) {
if (fileList.length > 0 && fileList[0].originFileObj) {
// TODO: Upload ảnh mới
// await apiUploadPhoto('fish', fish!.id!, fileList[0].originFileObj);
try {
const resp = await apiUploadPhoto(
'fish',
fish!.id!.toString(),
fileList[0].originFileObj,
);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success(
intl.formatMessage({
id: 'fish.update.image.success',
defaultMessage: 'Cập nhật ảnh cá thành công',
}),
);
onReload?.(true);
} else {
throw new Error('Cập nhật ảnh thất bại');
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'fish.update.image.fail',
defaultMessage: 'Cập nhật ảnh cá thất bại',
}),
);
return true;
}
} else {
// TODO: Xóa ảnh (nếu có API delete)
console.log('Remove photo');
const resp = await apiDeletePhoto('fish', fish!.id!);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
message.success(
intl.formatMessage({
id: 'fish.delete.image.success',
defaultMessage: 'Xóa ảnh cá thành công',
}),
);
onReload?.(true);
} else {
message.error(
intl.formatMessage({
id: 'fish.delete.image.fail',
defaultMessage: 'Xóa ảnh cá thất bại',
}),
);
return true;
}
}
}
}
return true;
}}
>
{type === 'create' && (
<div
style={{
display: 'flex',
justifyContent: 'center',
marginBottom: 16,
}}
>
<ProFormUploadButton
name="upload"
label={null}
title="Chọn ảnh"
accept="image/*"
max={1}
transform={(value) => ({ upload: value })}
fieldProps={{
onChange(info) {
setFileList(info.fileList);
},
listType: 'picture-card',
fileList: fileList,
onRemove: () => {
setFileList([]);
},
}}
/>
</div>
)}
<ProForm.Group>
<ProFormText
name="name"
width="md"
label={intl.formatMessage({
id: 'common.name',
defaultMessage: 'Tên',
})}
tooltip={intl.formatMessage({
id: 'fish.name.tooltip',
defaultMessage: 'Tên loài cá',
})}
placeholder={intl.formatMessage({
id: 'fish.name.placeholder',
defaultMessage: 'Nhập tên cá',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'fish.name.required',
defaultMessage: 'Tên cá không được để trống',
}),
},
]}
/>
<ProFormText
name="scientific_name"
width="md"
label={intl.formatMessage({
id: 'fish.specific_name',
defaultMessage: 'Tên khoa học',
})}
placeholder={intl.formatMessage({
id: 'fish.specific_name.placeholder',
defaultMessage: 'Nhập tên khoa học',
})}
/>
</ProForm.Group>
<ProForm.Group>
<ProFormText
name="group_name"
label={intl.formatMessage({
id: 'fish.fish_group',
defaultMessage: 'Nhóm',
})}
width="md"
tooltip={intl.formatMessage({
id: 'fish.fish_group.tooltip',
defaultMessage: 'Nhóm cá',
})}
placeholder={intl.formatMessage({
id: 'fish.fish_group.placeholder',
defaultMessage: 'Nhập nhóm cá',
})}
/>
<ProFormSelect
name="rarity_level"
label={intl.formatMessage({
id: 'fish.rarity',
defaultMessage: 'Độ hiếm',
})}
width="md"
placeholder={intl.formatMessage({
id: 'fish.rarity.placeholder',
defaultMessage: 'Chọn độ hiếm',
})}
options={[
{
label: intl.formatMessage({
id: 'fish.rarity.normal',
defaultMessage: 'Phổ biến',
}),
value: 1,
},
{
label: intl.formatMessage({
id: 'fish.rarity.sensitive',
defaultMessage: 'Dễ bị tổn thương',
}),
value: 2,
},
{
label: intl.formatMessage({
id: 'fish.rarity.near_threatened',
defaultMessage: 'Gần bị đe dọa',
}),
value: 3,
},
{
label: intl.formatMessage({
id: 'fish.rarity.endangered',
defaultMessage: 'Nguy cấp',
}),
value: 4,
},
]}
/>
</ProForm.Group>
<ProFormTextArea
name="note"
label={intl.formatMessage({
id: 'common.description',
defaultMessage: 'Ghi chú',
})}
placeholder={intl.formatMessage({
id: 'common.description.placeholder',
defaultMessage: 'Nhập ghi chú',
})}
/>
</ModalForm>
);
};
export default AddOrUpdateFish;

View File

@@ -0,0 +1,66 @@
import { HTTPSTATUS } from '@/constants';
import { apiGetPhoto } from '@/services/slave/sgw/PhotoController';
import { Image, Spin } from 'antd';
import { useEffect, useRef, useState } from 'react';
export const FishImage = ({
fishId,
alt,
isReload,
}: {
fishId: string;
alt: string;
isReload: boolean;
}) => {
const [url, setUrl] = useState<string>('');
const [loading, setLoading] = useState(true);
const objectUrlRef = useRef<string>('');
useEffect(() => {
let isMounted = true;
const fetchImage = async () => {
try {
const resp = await apiGetPhoto('fish', fishId);
let objectUrl = '';
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
const blob = new Blob([resp.data], { type: 'image/jpeg' });
objectUrl = URL.createObjectURL(blob);
objectUrlRef.current = objectUrl;
} else {
throw new Error('Failed to fetch image');
}
if (isMounted) {
setUrl(objectUrl);
setLoading(false);
}
} catch (error) {
// console.log('Error: ', error);
setUrl('');
if (isMounted) {
setLoading(false);
}
}
};
fetchImage();
return () => {
isMounted = false;
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
}
};
}, [fishId, isReload]);
if (loading) {
return <Spin size="small" />;
}
if (!url) {
return <span>-</span>;
}
return <Image height={50} width={50} src={url} alt={alt} />;
};

View File

@@ -1,11 +1,350 @@
import React from 'react';
import PhotoActionModal from '@/components/shared/PhotoActionModal';
import { DEFAULT_PAGE_SIZE, HTTPSTATUS } from '@/constants';
import {
apiDeleteFishSpecies,
apiGetFishSpecies,
} from '@/services/slave/sgw/FishController';
import { getRarityById } from '@/utils/slave/sgw/fishRarity';
import {
DeleteOutlined,
EditOutlined,
PictureOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, Flex, message, Popconfirm, Tag, theme, Tooltip } from 'antd';
import { useRef, useState } from 'react';
import AddOrUpdateFish from './component/AddOrUpdateFish';
import { FishImage } from './component/FishImage';
const FishList = () => {
const tableRef = useRef<ActionType>();
const intl = useIntl();
const [messageApi, contextHolder] = message.useMessage();
const [showAddOrUpdateModal, setShowAddOrUpdateModal] =
useState<boolean>(false);
const [fishSelected, setFishSelected] = useState<SgwModel.Fish | undefined>(
undefined,
);
const [fishPhotoModalOpen, setFishPhotoModalOpen] = useState<boolean>(false);
const [fishID, setFishID] = useState<number | undefined>(undefined);
const [isReloadImage, setIsReloadImage] = useState<boolean>(false);
const token = theme.useToken();
const getColorByRarityLevel = (level: number) => {
switch (level) {
case 2:
return token.token.yellow;
case 3:
return token.token.orange;
case 4:
return token.token.colorError;
case 5:
return '#FF6347';
case 6:
return '#FF4500';
case 7:
return '#FF0000';
case 8:
return '#8B0000';
default:
return token.token.green;
}
};
const handleDeleteFish = async (id: string) => {
try {
const resp = await apiDeleteFishSpecies(id);
if (resp.status === HTTPSTATUS.HTTP_SUCCESS) {
messageApi.success(
intl.formatMessage({
id: 'fish.delete.success',
defaultMessage: 'Successfully deleted fish',
}),
);
tableRef.current?.reload();
} else {
throw new Error('Xóa cá thất bại');
}
} catch (error) {
messageApi.error(
intl.formatMessage({
id: 'fish.delete.fail',
defaultMessage: 'Failed to delete fish',
}),
);
}
};
const columns: ProColumns<SgwModel.Fish>[] = [
{
title: intl.formatMessage({
id: 'common.name',
defaultMessage: 'Name',
}),
dataIndex: 'name',
key: 'name',
copyable: true,
render: (dom, entity) => {
return (
<Tooltip
title={
intl.formatMessage({
id: 'fish.specific_name',
defaultMessage: 'Scientific Name',
}) +
': ' +
entity.scientific_name
}
>
{dom}
</Tooltip>
);
},
},
{
title: intl.formatMessage({
id: 'common.image',
defaultMessage: 'Image',
}),
dataIndex: 'id',
key: 'id',
hideInSearch: true,
// valueType: 'image',
render: (_, entity) => {
return (
<FishImage
fishId={String(entity.id || '')}
alt={entity.name || ''}
isReload={isReloadImage}
/>
);
},
},
{
title: intl.formatMessage({
id: 'fish.fish_group',
defaultMessage: 'Group',
}),
dataIndex: 'group_name',
key: 'group_name',
},
{
title: intl.formatMessage({
id: 'fish.rarity',
defaultMessage: 'Rarity',
}),
dataIndex: 'rarity_level',
key: 'rarity_level',
valueType: 'select',
valueEnum: {
1: {
text: intl.formatMessage({
id: 'fish.rarity.normal',
defaultMessage: 'Common',
}),
},
2: {
text: intl.formatMessage({
id: 'fish.rarity.sensitive',
defaultMessage: 'Sensitive',
}),
},
3: {
text: intl.formatMessage({
id: 'fish.rarity.near_threatened',
defaultMessage: 'Near Threatened',
}),
},
4: {
text: intl.formatMessage({
id: 'fish.rarity.endangered',
defaultMessage: 'Endangered',
}),
},
},
render: (_, entity) => {
const rarity = getRarityById(entity.rarity_level || 1);
return (
<Tag color={getColorByRarityLevel(entity.rarity_level || 1)}>
{rarity ||
intl.formatMessage({
id: 'common.undefined',
defaultMessage: 'Undefined',
})}
</Tag>
);
},
},
{
title: intl.formatMessage({
id: 'common.note',
defaultMessage: 'Note',
}),
dataIndex: 'note',
hideInSearch: true,
key: 'note',
ellipsis: true,
},
{
title: intl.formatMessage({
id: 'common.actions',
defaultMessage: 'Actions',
}),
dataIndex: 'actions',
key: 'actions',
hideInSearch: true,
align: 'center',
render: (_, entity) => {
return (
<Flex align="center" justify="center" gap={8}>
<Button
type="text"
onClick={() => {
setFishSelected(entity);
setShowAddOrUpdateModal(true);
}}
icon={<EditOutlined />}
></Button>
<Button
type="text"
onClick={async () => {
setFishID(entity.id);
setFishPhotoModalOpen(true);
}}
icon={<PictureOutlined />}
></Button>
<Popconfirm
title={intl.formatMessage({
id: 'fish.delete_confirm',
defaultMessage:
'Are you sure you want to delete this fish species?',
})}
onConfirm={() => handleDeleteFish(entity.id?.toString() || '')}
okText={intl.formatMessage({
id: 'common.yes',
defaultMessage: 'Yes',
})}
cancelText={intl.formatMessage({
id: 'common.no',
defaultMessage: 'No',
})}
>
<Button type="text" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</Flex>
);
},
},
];
const SGWFish: React.FC = () => {
return (
<div>
<h1> (SGW Manager)</h1>
{contextHolder}
<AddOrUpdateFish
type={fishSelected ? 'update' : 'create'}
isOpen={showAddOrUpdateModal}
setIsOpen={setShowAddOrUpdateModal}
fish={fishSelected}
message={messageApi}
onReload={(isSuccess) => {
if (isSuccess) {
tableRef.current?.reload();
setIsReloadImage((prev) => !prev);
}
}}
/>
<PhotoActionModal
key={fishID ?? 'none'}
isOpen={fishPhotoModalOpen}
setIsOpen={setFishPhotoModalOpen}
type={'fish'}
id={fishID!}
hasSubPhotos={true}
/>
<ProTable<SgwModel.Fish>
actionRef={tableRef}
rowKey="id"
size="large"
columns={columns}
search={{
defaultCollapsed: false,
}}
columnEmptyText="-"
pagination={{
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
showSizeChanger: true,
pageSizeOptions: ['5', '10', '15', '20'],
showTotal: (total, range) =>
`${range[0]}-${range[1]}
${intl.formatMessage({
id: 'common.of',
defaultMessage: 'of',
})}
${total} ${intl.formatMessage({
id: 'fish.name',
defaultMessage: 'fishes',
})}`,
}}
options={{
search: false,
setting: false,
density: false,
reload: true,
}}
toolBarRender={() => [
<Button
color="cyan"
variant="outlined"
key="add-fish"
icon={<PlusOutlined />}
size="middle"
onClick={() => {
setFishSelected(undefined);
setShowAddOrUpdateModal(true);
}}
>
<FormattedMessage
id="fish.create.title"
defaultMessage="Add Fish Species"
/>
</Button>,
]}
request={async (params) => {
const { current, pageSize, name, group_name, rarity_level } = params;
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
const body: SgwModel.SearchFishPaginationBody = {
name: name,
order: 'name',
limit: pageSize,
offset: offset,
dir: 'desc',
};
if (group_name || rarity_level) body.metadata = {};
if (group_name && body.metadata) {
body.metadata.group_name = group_name;
}
if (rarity_level && body.metadata) {
body.metadata.rarity_level = Number(rarity_level);
}
try {
const res = await apiGetFishSpecies(body);
return {
data: res.fishes,
total: res.total,
success: true,
};
} catch (error) {
return {
data: [],
total: 0,
success: false,
};
}
}}
/>
</div>
);
};
export default SGWFish;
export default FishList;

View File

@@ -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) {

View File

@@ -49,7 +49,7 @@ const uploadPhoto = async (
type: 'people',
id: string,
file: File,
): Promise<void> => {
): 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) {