feat(manager/devices): Add device management features including creation and editing

This commit is contained in:
2026-01-22 17:07:02 +07:00
parent 0bac8d0f25
commit 8b95a620c2
8 changed files with 840 additions and 3 deletions

View File

@@ -0,0 +1,210 @@
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
import { PlusOutlined } from '@ant-design/icons';
import {
ModalForm,
ProForm,
ProFormInstance,
ProFormSelect,
ProFormText,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button } from 'antd';
import { MessageInstance } from 'antd/es/message/interface';
import { useRef, useState } from 'react';
type CreateDeviceProps = {
message: MessageInstance;
onSuccess?: (isSuccess: boolean) => void;
};
type CreateDeviceFormValues = {
name: string;
external_id: string;
type: string;
address?: string;
group_id?: string;
};
const CreateDevice = ({ message, onSuccess }: CreateDeviceProps) => {
const formRef = useRef<ProFormInstance<CreateDeviceFormValues>>();
const intl = useIntl();
const [group_id, setGroupId] = useState<string | string[] | null>(null);
const handleGroupSelect = (group: string | string[] | null) => {
setGroupId(group);
formRef.current?.setFieldsValue({
group_id: Array.isArray(group) ? group.join(',') : group || undefined,
});
};
return (
<ModalForm<CreateDeviceFormValues>
title={intl.formatMessage({
id: 'master.devices.register.title',
defaultMessage: 'Register New Device',
})}
formRef={formRef}
trigger={
<Button type="primary" key="primary" icon={<PlusOutlined />}>
<FormattedMessage
id="master.devices.register"
defaultMessage="Register"
/>
</Button>
}
autoFocusFirstInput
onFinish={async (values: CreateDeviceFormValues) => {
// TODO: Implement API call to create device
console.log('Create device with values:', values);
try {
// Placeholder for API call
// const body = {
// name: values.name,
// metadata: {
// external_id: values.external_id,
// type: values.type,
// address: values.address,
// group_id: values.group_id,
// },
// };
// const resp = await apiCreateDevice(body);
message.success(
intl.formatMessage({
id: 'master.devices.create.success',
defaultMessage: 'Create device successfully',
}),
);
formRef.current?.resetFields();
onSuccess?.(true);
return true;
} catch (error) {
message.error(
intl.formatMessage({
id: 'master.devices.create.error',
defaultMessage: 'Failed to create device',
}),
);
onSuccess?.(false);
return false;
}
}}
>
<ProFormText
name={'name'}
label={intl.formatMessage({
id: 'master.devices.name',
defaultMessage: 'Name',
})}
placeholder={intl.formatMessage({
id: 'master.devices.name.placeholder',
defaultMessage: 'Enter device name',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.name.required',
defaultMessage: 'The device name is required',
}),
},
]}
/>
<ProFormText
name={'external_id'}
label={intl.formatMessage({
id: 'master.devices.external_id',
defaultMessage: 'External ID',
})}
placeholder={intl.formatMessage({
id: 'master.devices.external_id.placeholder',
defaultMessage: 'Enter external ID',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.external_id.required',
defaultMessage: 'The external ID is required',
}),
},
]}
/>
<ProFormSelect
name="type"
label={intl.formatMessage({
id: 'master.devices.type',
defaultMessage: 'Type',
})}
options={[
{
label: intl.formatMessage({
id: 'master.devices.type.gms',
defaultMessage: 'GMS',
}),
value: 'gms',
},
{
label: intl.formatMessage({
id: 'master.devices.type.sgw',
defaultMessage: 'SGW',
}),
value: 'sgw',
},
{
label: intl.formatMessage({
id: 'master.devices.type.spole',
defaultMessage: 'SPOLE',
}),
value: 'spole',
},
]}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.type.required',
defaultMessage: 'Please select a device type',
}),
},
]}
/>
<ProFormText
name={'address'}
label={intl.formatMessage({
id: 'master.devices.address',
defaultMessage: 'Address',
})}
placeholder={intl.formatMessage({
id: 'master.devices.address.placeholder',
defaultMessage: 'Enter device address',
})}
/>
<ProForm.Item
name="group_id"
label={intl.formatMessage({
id: 'master.devices.groups',
defaultMessage: 'Groups',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.groups.required',
defaultMessage: 'Please select groups!',
}),
},
]}
>
<TreeSelectedGroup groupIds={group_id} onSelected={handleGroupSelect} />
</ProForm.Item>
</ModalForm>
);
};
export default CreateDevice;

View File

@@ -0,0 +1,133 @@
import { useIntl } from '@umijs/max';
import { Form, Input, Modal } from 'antd';
import React, { useEffect } from 'react';
interface Props {
visible: boolean;
device: MasterModel.Thing | null;
onCancel: () => void;
onSubmit: (values: {
name: string;
external_id: string;
address?: string;
}) => void;
}
const EditDeviceModal: React.FC<Props> = ({
visible,
device,
onCancel,
onSubmit,
}) => {
const [form] = Form.useForm();
const intl = useIntl();
useEffect(() => {
if (device) {
form.setFieldsValue({
name: device.name,
external_id: device?.metadata?.external_id,
address: device?.metadata?.address,
});
} else {
form.resetFields();
}
}, [device, form]);
return (
<Modal
title={intl.formatMessage({
id: 'master.devices.update.title',
defaultMessage: 'Update device',
})}
open={visible}
onCancel={onCancel}
onOk={() => form.submit()}
okText={intl.formatMessage({
id: 'master.devices.ok',
defaultMessage: 'OK',
})}
cancelText={intl.formatMessage({
id: 'master.devices.cancel',
defaultMessage: 'Cancel',
})}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={onSubmit} preserve={false}>
<Form.Item
name="name"
label={intl.formatMessage({
id: 'master.devices.name',
defaultMessage: 'Name',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.name.required',
defaultMessage: 'Please enter device name',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'master.devices.name.placeholder',
defaultMessage: 'Enter device name',
})}
/>
</Form.Item>
<Form.Item
name="external_id"
label={intl.formatMessage({
id: 'master.devices.external_id',
defaultMessage: 'External ID',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.external_id.required',
defaultMessage: 'Please enter external ID',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'master.devices.external_id.placeholder',
defaultMessage: 'Enter external ID',
})}
/>
</Form.Item>
<Form.Item
name="address"
label={intl.formatMessage({
id: 'master.devices.address',
defaultMessage: 'Address',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.devices.address.required',
defaultMessage: 'Please enter address',
}),
},
]}
>
<Input
placeholder={intl.formatMessage({
id: 'master.devices.address.placeholder',
defaultMessage: 'Enter address',
})}
/>
</Form.Item>
</Form>
</Modal>
);
};
export default EditDeviceModal;

View File

@@ -1,5 +1,319 @@
import IconFont from '@/components/IconFont';
import TreeGroup from '@/components/shared/TreeGroup';
import { DEFAULT_PAGE_SIZE } from '@/constants';
import { apiSearchThings } from '@/services/master/ThingController';
import { EditOutlined, EnvironmentOutlined } from '@ant-design/icons';
import {
ActionType,
ProCard,
ProColumns,
ProTable,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Button, Divider, Grid, Space, Tag, theme } from 'antd';
import message from 'antd/es/message';
import Paragraph from 'antd/lib/typography/Paragraph';
import { useRef, useState } from 'react';
import CreateDevice from './components/CreateDevice';
import EditDeviceModal from './components/EditDeviceModal';
const ManagerDevicePage = () => {
return <div>ManagerDevicePage</div>;
const { useBreakpoint } = Grid;
const intl = useIntl();
const screens = useBreakpoint();
const { token } = theme.useToken();
const actionRef = useRef<ActionType | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [messageApi, contextHolder] = message.useMessage();
const [selectedRowsState, setSelectedRowsState] = useState<
MasterModel.Thing[]
>([]);
const [groupCheckedKeys, setGroupCheckedKeys] = useState<
string | string[] | null
>(null);
const [isEditModalVisible, setIsEditModalVisible] = useState<boolean>(false);
const [editingDevice, setEditingDevice] = useState<MasterModel.Thing | null>(
null,
);
const handleClickAssign = (device: MasterModel.Thing) => {
console.log('Device ', device);
};
const handleEdit = (device: MasterModel.Thing) => {
setEditingDevice(device);
setIsEditModalVisible(true);
};
const handleEditCancel = () => {
setIsEditModalVisible(false);
setEditingDevice(null);
};
const handleEditSubmit = async (values: any) => {
// TODO: call update API here if available. For now just simulate success.
console.log('Update values for', editingDevice?.id, values);
messageApi.success('Cập nhật thành công');
setIsEditModalVisible(false);
setEditingDevice(null);
actionRef.current?.reload();
};
const columns: ProColumns<MasterModel.Thing>[] = [
{
key: 'name',
title: (
<FormattedMessage id="master.devices.name" defaultMessage="Name" />
),
tip: intl.formatMessage({
id: 'master.devices.name.tip',
defaultMessage: 'The device name',
}),
dataIndex: 'name',
render: (_, record) => (
<Paragraph
style={{
marginBottom: 0,
verticalAlign: 'middle',
display: 'inline-block',
color: token.colorText,
}}
copyable
>
{record?.name}
</Paragraph>
),
},
{
key: 'external_id',
title: (
<FormattedMessage
id="master.devices.external_id"
defaultMessage="External ID"
/>
),
tip: intl.formatMessage({
id: 'master.devices.external_id.tip',
defaultMessage: 'The external identifier',
}),
responsive: ['lg', 'md'],
dataIndex: ['metadata', 'external_id'],
render: (_, record) =>
record?.metadata?.external_id ? (
<Paragraph
style={{
marginBottom: 0,
verticalAlign: 'middle',
display: 'inline-block',
color: token.colorText,
}}
copyable
>
{record?.metadata?.external_id}
</Paragraph>
) : (
'-'
),
},
{
key: 'type',
hideInSearch: true,
title: (
<FormattedMessage id="master.devices.type" defaultMessage="Type" />
),
tip: intl.formatMessage({
id: 'master.devices.type.tip',
defaultMessage: 'The device type',
}),
dataIndex: ['metadata', 'type'],
render: (_, record) => record?.metadata?.type || '...',
},
{
key: 'connected',
hideInSearch: true,
title: <FormattedMessage id="common.status" defaultMessage="Status" />,
dataIndex: ['metadata', 'connected'],
render: (_, record) => (
<Tag color={record?.metadata?.connected ? 'green' : 'red'}>
{record?.metadata?.connected
? intl.formatMessage({
id: 'master.devices.online',
defaultMessage: 'Online',
})
: intl.formatMessage({
id: 'master.devices.offline',
defaultMessage: 'Offline',
})}
</Tag>
),
},
{
title: (
<FormattedMessage id="common.actions" defaultMessage="Operating" />
),
hideInSearch: true,
render: (_, device) => {
return (
<Space
size={5}
split={<Divider type="vertical" style={{ margin: '0 4px' }} />}
>
<Button
shape="default"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(device)}
/>
<Button
shape="default"
size="small"
icon={<EnvironmentOutlined />}
// onClick={() => handleClickAssign(device)}
/>
<Button
shape="default"
size="small"
icon={<IconFont type="icon-camera" />}
// onClick={() => handleClickAssign(device)}
/>
{device?.metadata?.type === 'gmsv6' && (
<Button
shape="default"
size="small"
icon={<IconFont type="icon-terminal" />}
// onClick={() => handleClickAssign(device)}
/>
)}
</Space>
);
},
},
];
return (
<>
<EditDeviceModal
visible={isEditModalVisible}
device={editingDevice}
onCancel={handleEditCancel}
onSubmit={handleEditSubmit}
/>
{contextHolder}
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard colSpan={{ xs: 24, sm: 24, md: 6, lg: 6, xl: 6 }}>
<TreeGroup
disable={isLoading}
multiple={true}
groupIds={groupCheckedKeys}
onSelected={(value: string | string[] | null) => {
setGroupCheckedKeys(value);
if (actionRef.current) {
actionRef.current.reload();
}
}}
/>
</ProCard>
<ProCard colSpan={{ xs: 24, sm: 24, md: 18, lg: 18, xl: 18 }}>
<ProTable<MasterModel.Thing>
columns={columns}
tableLayout="auto"
actionRef={actionRef}
rowKey="id"
search={{
layout: 'vertical',
defaultCollapsed: false,
}}
dateFormatter="string"
rowSelection={{
selectedRowKeys: selectedRowsState.map((row) => row.id!),
onChange: (_: React.Key[], selectedRows: MasterModel.Thing[]) => {
setSelectedRowsState(selectedRows);
},
}}
pagination={{
defaultPageSize: DEFAULT_PAGE_SIZE * 2,
showSizeChanger: true,
pageSizeOptions: ['10', '15', '20'],
showTotal: (total, range) =>
`${range[0]}-${range[1]}
${intl.formatMessage({
id: 'common.paginations.of',
defaultMessage: 'of',
})}
${total} ${intl.formatMessage({
id: 'master.devices.table.pagination',
defaultMessage: 'devices',
})}`,
}}
request={async (params = {}) => {
const { current = 1, pageSize, name, external_id } = params;
const size = pageSize || DEFAULT_PAGE_SIZE * 2;
const offset = current === 1 ? 0 : (current - 1) * size;
setIsLoading(true);
const metadata: Partial<MasterModel.ThingMetadata> = {};
if (external_id) metadata.external_id = external_id;
// Add group filter if groups are selected
if (groupCheckedKeys && groupCheckedKeys.length > 0) {
const groupId = Array.isArray(groupCheckedKeys)
? groupCheckedKeys.join(',')
: groupCheckedKeys;
metadata.group_id = groupId;
}
const query: MasterModel.SearchThingPaginationBody = {
offset: offset,
limit: size,
order: 'name',
dir: 'asc',
};
if (name) query.name = name;
if (Object.keys(metadata).length > 0) query.metadata = metadata;
try {
const response = await apiSearchThings(query);
setIsLoading(false);
return {
data: response.things || [],
success: true,
total: response.total || 0,
};
} catch (error) {
setIsLoading(false);
return {
data: [],
success: false,
total: 0,
};
}
}}
options={{
search: false,
setting: false,
density: false,
reload: true,
}}
toolBarRender={() => [
<CreateDevice
message={messageApi}
onSuccess={(isSuccess) => {
if (isSuccess) {
actionRef.current?.reload();
}
}}
key="create-device"
/>,
]}
/>
</ProCard>
</ProCard>
</>
);
};
export default ManagerDevicePage;