feat(project): base smatec's frontend

This commit is contained in:
Tran Anh Tuan
2026-01-21 11:48:57 +07:00
commit 5c2a909bed
138 changed files with 43666 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
const ManagerDevicePage = () => {
return <div>ManagerDevicePage</div>;
};
export default ManagerDevicePage;

View File

@@ -0,0 +1,300 @@
import {
apiCreateGroup,
apiUpdateGroup,
} from '@/services/master/GroupController';
import {
ModalForm,
ProFormInstance,
ProFormText,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
import { MessageInstance } from 'antd/es/message/interface';
import { useEffect, useRef } from 'react';
type CreateOrUpdateGroupProps = {
type: 'create-root' | 'create-child' | 'update';
isOpen?: boolean;
setIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
group?: MasterModel.GroupNode | null;
message: MessageInstance;
};
type HandleGroupForm = {
parent_id?: string;
parent_name?: string;
name: string;
code: string;
short_name: string;
description?: string;
};
const CreateOrUpdateGroup = ({
type,
isOpen,
setIsOpen,
group,
message,
}: CreateOrUpdateGroupProps) => {
const formRef = useRef<ProFormInstance<HandleGroupForm>>();
const intl = useIntl();
const { groups, getGroups } = useModel('master.useGroups');
// Sync form values when modal opens or group/type changes
useEffect(() => {
console.log(
'useEffect trigger - isOpen:',
isOpen,
'group:',
group,
'type:',
type,
);
if (isOpen && formRef.current) {
if (type === 'update' && group) {
formRef.current.setFieldsValue({
parent_id: group?.id || '',
parent_name: '',
name: group?.name || '',
code: group?.metadata?.code || '',
short_name: group?.metadata?.short_name || '',
description: group?.description || '',
});
} else if (type === 'create-child' && group) {
formRef.current.setFieldsValue({
parent_id: group?.id,
parent_name: group?.name || '',
name: '',
code: '',
short_name: '',
description: '',
});
} else if (type === 'create-root') {
formRef.current.setFieldsValue({
parent_id: '',
parent_name: '',
name: '',
code: '',
short_name: '',
description: '',
});
}
}
}, [isOpen, group, type]);
const handleOpenChange = (open: boolean) => {
setIsOpen(open);
};
return (
<ModalForm<HandleGroupForm>
open={isOpen}
onOpenChange={handleOpenChange}
layout="vertical"
formRef={formRef}
title={
group === undefined ? (
<FormattedMessage id="master.groups.root" />
) : (
<FormattedMessage id="master.groups.add" />
)
}
onFinish={async (values) => {
const body: Partial<MasterModel.GroupBodyRequest> = {
name: values.name,
description: values.description,
metadata: {
code: values.code,
short_name: values.short_name,
},
};
switch (type) {
case 'update': {
body.id = group?.id;
try {
const response = await apiUpdateGroup(body);
if (response.id) {
message?.success(
intl.formatMessage({
id: 'master.groups.update.success',
defaultMessage: 'Updated successfully',
}),
);
getGroups();
return true;
}
} catch (error) {
console.log('Error when update group: ', error);
message?.error(
intl.formatMessage({
id: 'master.groups.update.failed',
defaultMessage: 'Update failed, please try again!',
}),
);
return false;
}
break;
}
case 'create-child': {
body.parent_id = group?.id;
try {
const response = await apiCreateGroup(body);
if (response.id) {
message?.success(
intl.formatMessage({
id: 'master.groups.create.success',
defaultMessage: 'Created group successfully',
}),
);
setIsOpen(false);
getGroups();
return true;
}
} catch (error) {
console.log('Error when create group: ', error);
message?.error(
intl.formatMessage({
id: 'master.groups.create.failed',
defaultMessage: 'Create group failed, please try again!',
}),
);
return false;
}
break;
}
case 'create-root': {
try {
const response = await apiCreateGroup(body);
if (response.id) {
message?.success(
intl.formatMessage({
id: 'master.groups.create.success',
defaultMessage: 'Created group successfully',
}),
);
setIsOpen(false);
getGroups();
return true;
} else {
throw new Error('Create group failed');
}
} catch (error) {
console.log('Error when create group: ', error);
message?.error(
intl.formatMessage({
id: 'master.groups.create.failed',
defaultMessage: 'Create group failed, please try again!',
}),
);
return false;
}
}
}
}}
>
<ProFormText name="parent_id" hidden />
{type !== 'update' && (
<ProFormText
name="parent_name"
disabled
label={intl.formatMessage({
id: 'master.groups.parent',
defaultMessage: 'Parent',
})}
/>
)}
<ProFormText
name="name"
label={intl.formatMessage({
id: 'common.name',
defaultMessage: 'Name',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="common.name.required"
defaultMessage="The name is required"
/>
),
},
]}
/>
<ProFormText
name="code"
label={intl.formatMessage({
id: 'master.groups.code',
defaultMessage: 'Code',
})}
rules={[
{ required: true },
{
validator: async (_, value) => {
if (
value &&
groups!.some(
(g) => g?.metadata?.code === value && type !== 'update',
)
) {
throw new Error(
intl.formatMessage({
id: 'master.groups.code.exists',
defaultMessage: 'The code already exists',
}),
);
}
},
},
]}
/>
<ProFormText
name="short_name"
label={intl.formatMessage({
id: 'master.groups.short_name',
defaultMessage: 'Short name',
})}
rules={[
{ required: true },
{
validator: async (_, value) => {
if (
value &&
groups!.some(
(g) => g?.metadata?.short_name === value && type !== 'update',
)
) {
throw new Error(
intl.formatMessage({
id: 'master.groups.short_name.exists',
defaultMessage: 'The short name already exists',
}),
);
}
},
},
]}
/>
<ProFormText
name="description"
label={intl.formatMessage({
id: 'common.description',
defaultMessage: 'Description',
})}
rules={[
{
required: true,
message: (
<FormattedMessage
id="common.description.required"
defaultMessage="The description is required"
/>
),
},
]}
/>
</ModalForm>
);
};
export default CreateOrUpdateGroup;

View File

@@ -0,0 +1,451 @@
import IconFont from '@/components/IconFont';
import TreeGroup from '@/components/shared/TreeGroup';
import { HTTPSTATUS } from '@/constants';
import {
apiDeleteGroup,
apiUpdateGroup,
} from '@/services/master/GroupController';
import {
DeleteOutlined,
EditOutlined,
ExclamationCircleOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { ProCard, ProDescriptions } from '@ant-design/pro-components';
import { FormattedMessage, useIntl, useModel } from '@umijs/max';
import { Button, Grid, message, Modal } from 'antd';
import { Dropdown, MenuProps, Tooltip } from 'antd/lib';
import { useState } from 'react';
import CreateOrUpdateGroup from './components/CreateOrUpdateGroup';
type MenuItem = Required<MenuProps>['items'][number];
const ManagerGroupPage = () => {
const { useBreakpoint } = Grid;
const { initialState } = useModel('@@initialState');
const { currentUserProfile } = initialState || {};
const intl = useIntl();
const screens = useBreakpoint();
const [selectedItem, setSelectedItem] = useState<
MasterModel.GroupNode | undefined
>();
const [selectedGroup, setSelectedGroup] = useState<
MasterModel.GroupNode | undefined
>();
const { groups, getGroups } = useModel('master.useGroups');
const [handleChildModal, setHandleChildModal] = useState<boolean>(false);
const [type, setType] = useState<'create-root' | 'create-child' | 'update'>(
'create-root',
);
const [messageApi, contextHolder] = message.useMessage();
const [modalKey, setModalKey] = useState(0);
const findGroupById = (
groups: MasterModel.GroupNode[],
id: string,
): MasterModel.GroupNode | undefined => {
for (const group of groups) {
if (group.id === id) return group;
if (group.children) {
const found = findGroupById(group.children, id);
if (found) return found;
}
}
return undefined;
};
const handleUpdate = async (group: Partial<MasterModel.GroupNode>) => {
const key = 'update_group';
const { name, description, parent_id, code, short_name } = group;
const editGroup = parent_id
? {
...selectedItem,
name: name,
parent_id: parent_id,
metadata: {
code: code,
short_name: short_name,
},
description: description,
}
: {
...selectedItem,
name: name,
metadata: {
code: code,
short_name: short_name,
},
description: description,
};
try {
messageApi.open({
type: 'loading',
content: intl.formatMessage({
id: 'common.updating',
defaultMessage: 'updating...',
}),
key,
});
await apiUpdateGroup({ ...editGroup });
messageApi.open({
type: 'success',
content: intl.formatMessage({
id: 'master.groups.update.success',
defaultMessage: 'Updated successfully',
}),
key,
});
await getGroups();
return true;
} catch (error) {
messageApi.open({
type: 'error',
content: intl.formatMessage({
id: 'master.groups.update.failed',
defaultMessage: 'Updating failed, please try again!',
}),
key,
});
return false;
}
};
const showDeleteConfirm = (group: MasterModel.GroupNode) => {
Modal.confirm({
title: intl.formatMessage({
id: 'master.groups.delete.confirm',
defaultMessage: 'Are you sure delete this group',
}),
content: selectedItem?.name,
icon: <ExclamationCircleOutlined />,
okText: intl.formatMessage({ id: 'common.yes', defaultMessage: 'Yes' }),
okType: 'danger',
cancelText: intl.formatMessage({ id: 'common.no', defaultMessage: 'No' }),
async onOk() {
try {
const resp = await apiDeleteGroup(group.id);
if (resp.status === HTTPSTATUS.HTTP_NOCONTENT) {
messageApi.success(
intl.formatMessage({
id: 'master.groups.delete.success',
defaultMessage: 'Deleted group successfully',
}),
);
setSelectedItem(undefined);
await getGroups();
} else if (resp.status === HTTPSTATUS.HTTP_SERVERERROR) {
messageApi.warning(
intl.formatMessage({
id: 'master.groups.delete.failed_internal',
defaultMessage:
'The group contains devices or users and cannot be deleted',
}),
);
} else {
throw new Error('Delete group failed');
}
} catch (error) {
console.error('Error when delete group ', error);
messageApi.error(
intl.formatMessage({
id: 'master.groups.delete.failed',
defaultMessage: 'Delete group failed',
}),
);
}
},
});
};
const onItemClick = ({ key }: { key: string }) => {
const splitIndex = key.indexOf('-');
const action = key.substring(0, splitIndex);
const groupId = key.substring(splitIndex + 1);
const groupNode = findGroupById(groups || [], groupId);
console.log('GroupName', groupNode?.name);
setSelectedItem(groupNode);
setSelectedGroup(groupNode);
if (action === '1') {
setType('create-child');
} else if (action === '2') {
setType('update');
} else if (action === '3') {
showDeleteConfirm(groupNode!);
return;
}
// Increment modalKey to force remount component
setModalKey((prev) => prev + 1);
setHandleChildModal(true);
};
return (
<>
{contextHolder}
<CreateOrUpdateGroup
type={type}
key={modalKey}
isOpen={handleChildModal}
setIsOpen={setHandleChildModal}
group={selectedGroup}
message={messageApi}
/>
<ProCard split={screens.md ? 'vertical' : 'horizontal'}>
<ProCard
colSpan={{ xs: 24, sm: 24, md: 10, lg: 10, xl: 10 }}
extra={[
currentUserProfile?.metadata?.user_type === 'sysadmin' && (
<Button
type="primary"
key="primary"
icon={<PlusOutlined />}
onClick={() => {
setType('create-root');
setSelectedItem(undefined);
setModalKey((prev) => prev + 1);
setHandleChildModal(true);
}}
>
<FormattedMessage
id="master.groups.root"
defaultMessage="Add root"
/>
</Button>
),
]}
>
<TreeGroup
titleRender={(item) => {
const groupNode = findGroupById(groups || [], item.key as string);
const menus: MenuItem[] = [
{
label: (
<Tooltip
title={
groupNode?.metadata?.has_thing
? intl.formatMessage({
id: 'master.groups.cannot-add-group',
defaultMessage:
'Cannot add child to a group that has things',
})
: undefined
}
>
{intl.formatMessage({
id: 'master.groups.add',
defaultMessage: 'Add child',
})}
</Tooltip>
),
key: `1-${groupNode?.id}`,
icon: <IconFont type="icon-leaf" />,
disabled: groupNode?.metadata?.has_thing === true,
},
{
label: intl.formatMessage({
id: 'common.edit',
defaultMessage: 'Update',
}),
key: `2-${groupNode?.id}`,
icon: <EditOutlined />,
},
{
label: (
<Tooltip
title={
groupNode?.metadata?.has_thing
? intl.formatMessage({
id: 'master.groups.delete.failed_internal',
defaultMessage:
'The group contains devices or users and cannot be deleted',
})
: undefined
}
>
{intl.formatMessage({
id: 'common.delete',
defaultMessage: 'Delete',
})}
</Tooltip>
),
key: `3-${groupNode?.id}`,
icon: <DeleteOutlined />,
disabled: groupNode?.metadata?.has_thing === true,
},
];
return (
<Dropdown
menu={{
items: menus,
onClick: onItemClick,
}}
trigger={['contextMenu']}
placement="bottomRight"
arrow
>
<span>
{typeof item.title === 'function'
? item.title(item)
: item.title}
</span>
</Dropdown>
);
}}
multiple={false}
onSelected={(value: string | string[] | null) => {
if (!groups) {
setSelectedItem(undefined);
return;
}
// Find the selected group from model
if (value && !Array.isArray(value)) {
const found = findGroupById(groups, value);
setSelectedItem(found);
} else {
setSelectedItem(undefined);
}
}}
/>
</ProCard>
<ProCard colSpan={{ xs: 24, sm: 24, md: 14, lg: 14, xl: 14 }}>
{selectedItem?.name && (
<>
<ProDescriptions<Partial<MasterModel.GroupNode>>
column={1}
bordered
title={selectedItem.name}
extra={[
<Tooltip
key="add"
title={
selectedItem?.metadata?.has_thing
? intl.formatMessage({
id: 'master.groups.cannot-add-group',
defaultMessage:
'Cannot add child to a group that has things',
})
: undefined
}
>
<Button
key="add"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setType('create-child');
setModalKey((prev) => prev + 1);
setHandleChildModal(true);
}}
disabled={
selectedItem.metadata?.has_thing === true ? true : false
}
>
<FormattedMessage
id="master.groups.add"
defaultMessage="Add Group"
/>
</Button>
</Tooltip>,
<Tooltip
key="add-fail"
title={
selectedItem?.metadata?.has_thing
? intl.formatMessage({
id: 'master.groups.delete.failed_internal',
defaultMessage:
'Cannot add child to a group that has things',
})
: undefined
}
>
<Button
danger
key="delete"
title={intl.formatMessage({
id: 'master.groups.delete.confirm',
defaultMessage: 'Delete this group?',
})}
onClick={() => {
showDeleteConfirm(selectedItem);
}}
icon={<DeleteOutlined />}
disabled={
selectedItem.metadata?.has_thing === true ? true : false
}
>
<FormattedMessage
id="common.delete"
defaultMessage="Delete"
/>
</Button>
,
</Tooltip>,
]}
dataSource={{
name: selectedItem.name,
code: selectedItem.metadata?.code || '',
short_name: selectedItem.metadata?.short_name || '',
description: selectedItem.description || '',
}}
columns={[
{
title: intl.formatMessage({
id: 'common.name',
defaultMessage: 'Name',
}),
dataIndex: 'name',
},
{
title: intl.formatMessage({
id: 'master.groups.code',
defaultMessage: 'Code',
}),
dataIndex: 'code',
},
{
title: intl.formatMessage({
id: 'master.groups.short_name',
defaultMessage: 'Short name',
}),
dataIndex: 'short_name',
},
{
title: intl.formatMessage({
id: 'common.description',
defaultMessage: 'Description',
}),
dataIndex: 'description',
},
]}
editable={{
onSave: async (_, record) => {
const updatedData: MasterModel.GroupNode = {
...selectedItem,
name: record.name || '',
description: record.description || '',
metadata: {
...selectedItem.metadata,
code: record.code,
short_name: record.short_name,
},
};
const success = await handleUpdate(updatedData);
if (success) {
setSelectedItem(updatedData);
return true;
}
return false;
},
}}
/>
</>
)}
</ProCard>
</ProCard>
</>
);
};
export default ManagerGroupPage;

View File

@@ -0,0 +1,484 @@
import { DATE_TIME_FORMAT, DEFAULT_PAGE_SIZE } from '@/constants';
import { apiQueryLogs } from '@/services/master/LogController';
import { apiQueryUsers } from '@/services/master/UserController';
import { ActionType, ProColumns, ProTable } from '@ant-design/pro-components';
import { useIntl } from '@umijs/max';
import { DatePicker } from 'antd/lib';
import dayjs from 'dayjs';
import { useRef } from 'react';
const SystemLogs = () => {
const intl = useIntl();
const tableRef = useRef<ActionType>();
const actions = [
//Alarm
{
title: intl.formatMessage({
id: 'master.logs.things.alarm.confirm',
defaultMessage: 'Alarm confirm',
}),
value: '0-0',
selectable: false,
children: [
{
value: 'things.alarm_confirm',
title: intl.formatMessage({
id: 'master.logs.things.confirm',
defaultMessage: 'Confirm',
}),
},
{
value: 'things.alarm_unconfirm',
title: intl.formatMessage({
id: 'master.logs.things.unconfirm',
defaultMessage: 'Unconfirm',
}),
},
],
},
//Things
{
title: intl.formatMessage({
id: 'master.logs.things',
defaultMessage: 'Things',
}),
value: '0-1',
selectable: false,
children: [
{
value: 'things.create',
title: intl.formatMessage({
id: 'master.logs.things.create',
defaultMessage: 'Create new thing',
}),
},
{
value: 'things.update',
title: intl.formatMessage({
id: 'master.logs.things.update',
defaultMessage: 'Update thing',
}),
},
{
value: 'things.remove',
title: intl.formatMessage({
id: 'master.logs.things.remove',
defaultMessage: 'Remove thing',
}),
},
{
value: 'things.share',
title: intl.formatMessage({
id: 'master.logs.things.share',
defaultMessage: 'Share thing',
}),
},
{
value: 'things.unshare',
title: intl.formatMessage({
id: 'master.logs.things.unshare',
defaultMessage: 'Unshare thing',
}),
},
{
value: 'things.update_key',
title: intl.formatMessage({
id: 'master.logs.things.update_key',
defaultMessage: 'Update key thing',
}),
},
],
},
// Users
{
title: intl.formatMessage({
id: 'master.logs.users',
defaultMessage: 'Users',
}),
value: '0-2',
selectable: false,
children: [
{
value: 'users.create',
title: intl.formatMessage({
id: 'master.logs.users.create',
defaultMessage: 'Register user',
}),
},
{
value: 'users.update',
title: intl.formatMessage({
id: 'master.logs.users.update',
defaultMessage: 'Update user',
}),
},
{
value: 'users.remove',
title: intl.formatMessage({
id: 'master.logs.users.remove',
defaultMessage: 'Remove user',
}),
},
{
value: 'users.login',
title: intl.formatMessage({
id: 'master.logs.users.login',
defaultMessage: 'User login',
}),
},
],
},
// Groups
{
title: intl.formatMessage({
id: 'master.logs.groups',
defaultMessage: 'Groups',
}),
value: '0-3',
selectable: false,
children: [
{
value: 'group.create',
title: intl.formatMessage({
id: 'master.logs.groups.create',
defaultMessage: 'Create new group',
}),
},
{
value: 'group.update',
title: intl.formatMessage({
id: 'master.logs.groups.update',
defaultMessage: 'Update group',
}),
},
{
value: 'group.remove',
title: intl.formatMessage({
id: 'master.logs.groups.remove',
defaultMessage: 'Remove group',
}),
},
{
value: 'group.assign_thing',
title: intl.formatMessage({
id: 'master.logs.groups.assign_thing',
defaultMessage: 'Assign thing to group',
}),
},
{
value: 'group.assign_user',
title: intl.formatMessage({
id: 'master.logs.groups.assign_user',
defaultMessage: 'Assign user to group',
}),
},
{
value: 'group.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.groups.unassign_thing',
defaultMessage: 'Remove thing from group',
}),
},
{
value: 'group.unassign_user',
title: intl.formatMessage({
id: 'master.logs.groups.unassign_user',
defaultMessage: 'Remove user from group',
}),
},
],
},
// Ships
{
title: intl.formatMessage({
id: 'master.logs.ships',
defaultMessage: 'Ships',
}),
value: '0-4',
selectable: false,
children: [
{
value: 'ships.create',
title: intl.formatMessage({
id: 'master.logs.ships.create',
defaultMessage: 'Create new ship',
}),
},
{
value: 'ships.update',
title: intl.formatMessage({
id: 'master.logs.ships.update',
defaultMessage: 'Update ship',
}),
},
{
value: 'ships.remove',
title: intl.formatMessage({
id: 'master.logs.ships.remove',
defaultMessage: 'Remove ship',
}),
},
{
value: 'ships.assign_thing',
title: intl.formatMessage({
id: 'master.logs.ships.assign_thing',
defaultMessage: 'Assign thing to ship',
}),
},
{
value: 'ships.assign_user',
title: intl.formatMessage({
id: 'master.logs.ships.assign_user',
defaultMessage: 'Assign user to ship',
}),
},
{
value: 'ships.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.ships.unassign_thing',
defaultMessage: 'Remove thing from ship',
}),
},
{
value: 'ships.unassign_user',
title: intl.formatMessage({
id: 'master.logs.ships.unassign_user',
defaultMessage: 'Remove user from ship',
}),
},
],
},
// Trips
{
title: intl.formatMessage({
id: 'master.logs.trips',
defaultMessage: 'Trips',
}),
value: '0-5',
selectable: false,
children: [
{
value: 'trips.create',
title: intl.formatMessage({
id: 'master.logs.trips.create',
defaultMessage: 'Create new ship',
}),
},
{
value: 'trips.update',
title: intl.formatMessage({
id: 'master.logs.trips.update',
defaultMessage: 'Update ship',
}),
},
{
value: 'trips.remove',
title: intl.formatMessage({
id: 'master.logs.trips.remove',
defaultMessage: 'Remove ship',
}),
},
{
value: 'trips.approve',
title: intl.formatMessage({
id: 'master.logs.trips.approve',
defaultMessage: 'Approve trip',
}),
},
{
value: 'trips.request_approve',
title: intl.formatMessage({
id: 'master.logs.trips.request_approve',
defaultMessage: 'Request approval for trip',
}),
},
{
value: 'trips.unassign_thing',
title: intl.formatMessage({
id: 'master.logs.trips.unassign_thing',
defaultMessage: 'Remove thing from ship',
}),
},
{
value: 'trips.unassign_user',
title: intl.formatMessage({
id: 'master.logs.trips.unassign_user',
defaultMessage: 'Remove user from ship',
}),
},
],
},
];
const queryUserSource = async (): Promise<MasterModel.ProfileResponse[]> => {
try {
const body: MasterModel.SearchUserPaginationBody = {
offset: 0,
limit: 100,
order: 'email',
dir: 'asc',
};
const resp = await apiQueryUsers(body);
return resp?.users ?? [];
} catch {
return [];
}
};
const columns: ProColumns<MasterModel.Message>[] = [
{
title: intl.formatMessage({
id: 'master.logs.date.text',
defaultMessage: 'Date',
}),
key: 'dateTimeRange',
dataIndex: 'time',
width: '20%',
valueType: 'dateTimeRange',
initialValue: [dayjs().add(-1, 'day'), dayjs()],
search: {
transform: (value) => ({ startTime: value[0], endTime: value[1] }),
},
render: (_, row) => {
return dayjs.unix(row?.time || 0).format(DATE_TIME_FORMAT);
},
renderFormItem: (_, config, form) => {
return (
<DatePicker.RangePicker
width="50%"
presets={[
{
label: intl.formatMessage({
id: 'master.logs.search.yesterday',
defaultMessage: 'Yesterday',
}),
value: [dayjs().add(-1, 'day'), dayjs()],
},
{
label: intl.formatMessage({
id: 'master.logs.search.last_week',
defaultMessage: 'Last Week',
}),
value: [dayjs().add(-7, 'day'), dayjs()],
},
{
label: intl.formatMessage({
id: 'master.logs.search.last_month',
defaultMessage: 'Last Month',
}),
value: [dayjs().add(-30, 'day'), dayjs()],
},
]}
onChange={(dates) => {
form.setFieldsValue({ dateTimeRange: dates });
}}
/>
);
},
},
{
title: intl.formatMessage({
id: 'master.logs.email.text',
defaultMessage: 'Email',
}),
dataIndex: 'publisher',
valueType: 'select',
fieldProps: { mode: 'multiple' },
width: '25%',
request: async () => {
const users = await queryUserSource();
return users.map((u) => ({
label: u.email,
value: u.email,
}));
},
},
{
title: intl.formatMessage({
id: 'master.logs.action.text',
defaultMessage: 'Action',
}),
dataIndex: 'subtopic',
valueType: 'treeSelect',
width: '25%',
fieldProps: {
treeCheckable: true,
multiple: true,
},
request: async () => actions,
render: (_, item) => {
const childs = actions.flatMap((a) => a.children || []);
const action = childs.find((a) => a?.value === item.subtopic);
return action?.title ?? '...';
},
},
];
return (
<ProTable<MasterModel.Message>
actionRef={tableRef}
columns={columns}
rowKey={(item) =>
`${item.subtopic}:${item.time}:${item.publisher}:${item.name}`
}
size="large"
options={{
search: false,
setting: false,
density: false,
reload: true,
}}
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.logs.table.pagination',
defaultMessage: 'activities',
})}`,
}}
request={async (params) => {
try {
const { current, pageSize, subtopic, publisher, startTime, endTime } =
params;
const offset = current === 1 ? 0 : (current! - 1) * pageSize!;
const body: MasterModel.SearchLogPaginationBody = {
limit: pageSize,
offset: offset,
from: startTime
? typeof startTime === 'string'
? dayjs(startTime).unix()
: startTime.unix()
: undefined,
to: endTime
? typeof endTime === 'string'
? dayjs(endTime).unix()
: endTime.unix()
: undefined,
publisher: publisher?.length ? publisher.join(',') : undefined,
subtopic: subtopic?.length ? subtopic.join(',') : undefined,
};
const resp = await apiQueryLogs(body, 'user_logs');
return {
data: resp.messages || [],
success: true,
total: resp.total || 0,
};
} catch (error) {
console.error('Error when get logs: ', error);
return {
data: [],
success: false,
total: 0,
};
}
}}
/>
);
};
export default SystemLogs;

View File

@@ -0,0 +1,368 @@
import TreeSelectedGroup from '@/components/shared/TreeSelectedGroup';
import { HTTPSTATUS } from '@/constants';
import {
apiCreateUsers,
apiQueryUsers,
} from '@/services/master/UserController';
import { LockOutlined, PlusOutlined, UserOutlined } 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 CreateUserProps = {
message: MessageInstance;
onSuccess?: (isSuccess: boolean) => void;
};
type CreateUserFormValues = {
full_name: string;
phone_number: string;
user_type: 'admin' | 'enduser' | 'sysadmin' | 'users';
email: string;
password: string;
group_id?: string;
};
const RoleSelectForm = () => {
const domain = process.env.DOMAIN_ENV;
const intl = useIntl();
switch (domain) {
case 'sgw':
return (
<ProFormSelect
name="user_type"
label={intl.formatMessage({ id: 'master.users.role' })}
options={[
{
label: intl.formatMessage({
id: 'master.users.role.sgw.end_user',
}),
value: 'endusers',
},
{
label: intl.formatMessage({ id: 'master.users.role.user' }),
value: 'users',
},
{
label: intl.formatMessage({ id: 'master.users.role.admin' }),
value: 'admin',
},
]}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.users.role.placeholder',
defaultMessage: 'Please select a role',
}),
},
]}
/>
);
case 'spole':
return (
<ProFormSelect
name="user_type"
label={intl.formatMessage({ id: 'master.users.role' })}
options={[
{
label: intl.formatMessage({ id: 'master.users.role.user' }),
value: 'users',
},
{
label: intl.formatMessage({ id: 'master.users.role.admin' }),
value: 'admin',
},
]}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.users.role.placeholder',
defaultMessage: 'Please select a role',
}),
},
]}
/>
);
case 'gms':
default:
return (
<ProFormSelect
name="user_type"
label={intl.formatMessage({ id: 'master.users.role' })}
options={[
{
label: intl.formatMessage({ id: 'master.users.role.user' }),
value: 'users',
},
{
label: intl.formatMessage({ id: 'master.users.role.admin' }),
value: 'admin',
},
]}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.users.role.placeholder',
defaultMessage: 'Please select a role',
}),
},
]}
/>
);
}
};
const CreateUser = ({ message, onSuccess }: CreateUserProps) => {
const formRef = useRef<ProFormInstance<CreateUserFormValues>>();
const intl = useIntl();
const [group_id, setGroupId] = useState<string | string[] | null>(null);
const handleGroupSelect = (group: string | string[] | null) => {
setGroupId(group);
// Đồng bộ giá trị vào form để validation hoạt động
formRef.current?.setFieldsValue({
group_id: Array.isArray(group) ? group.join(',') : group || undefined,
});
};
return (
<ModalForm<CreateUserFormValues>
title={intl.formatMessage({
id: 'master.users.register.title',
defaultMessage: 'Register New User',
})}
formRef={formRef}
trigger={
<Button type="primary" key="primary" icon={<PlusOutlined />}>
<FormattedMessage
id="master.users.register"
defaultMessage="Register"
/>
</Button>
}
autoFocusFirstInput
onFinish={async (values: CreateUserFormValues) => {
const body: MasterModel.CreateUserBodyRequest = {
email: values.email,
password: values.password,
metadata: {
full_name: values.full_name,
phone_number: values.phone_number,
user_type: values.user_type || 'enduser',
group_id: values.group_id,
},
};
try {
const resp = await apiCreateUsers(body);
if (resp.status === HTTPSTATUS.HTTP_CREATED) {
message.success(
intl.formatMessage({
id: 'master.users.create.success',
defaultMessage: 'Create user successfully',
}),
);
formRef.current?.resetFields();
onSuccess?.(true);
return true;
} else {
throw new Error('Create user failed');
}
} catch (error) {
message.error(
intl.formatMessage({
id: 'master.users.create.error',
defaultMessage: 'Failed to create user',
}),
);
onSuccess?.(false);
return false;
}
}}
>
<ProFormText
name={'full_name'}
label={intl.formatMessage({
id: 'master.users.full_name',
defaultMessage: 'Full name',
})}
placeholder={intl.formatMessage({
id: 'master.users.full_name.placeholder',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.users.full_name.required',
defaultMessage: 'The full name is required',
}),
},
]}
/>
<RoleSelectForm />
<ProFormText
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.email.required"
defaultMessage="The email is required"
/>
),
},
{
type: 'email',
message: (
<FormattedMessage
id="master.users.email.invalid"
defaultMessage="Invalid email address"
/>
),
},
{
validator: async (rule, value) => {
if (value) {
const query: MasterModel.SearchUserPaginationBody = {
offset: 0,
limit: 1,
order: 'email',
email: value,
dir: 'asc',
};
const resp = await apiQueryUsers(query);
const { total } = resp;
if (total && total > 0) {
return Promise.reject(
new Error(
intl.formatMessage({
id: 'master.users.email.exists',
defaultMessage: 'The email is exists',
}),
),
);
}
}
return Promise.resolve();
},
},
]}
name="email"
label={intl.formatMessage({
id: 'master.users.email',
defaultMessage: 'Email',
})}
fieldProps={{
prefix: <UserOutlined />,
}}
placeholder={intl.formatMessage({
id: 'master.users.email.placeholder',
defaultMessage: 'Email',
})}
validateTrigger={['onBlur']}
/>
<ProFormText
name="phone_number"
label={intl.formatMessage({
id: 'master.users.phone_number',
defaultMessage: 'Phone number',
})}
fieldProps={{
autoComplete: 'phone-number',
}}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.users.phone_number.required',
defaultMessage: 'The phone number is required',
}),
},
{
pattern: /((09|03|07|08|05)+([0-9]{8})\b)/g,
message: intl.formatMessage({
id: 'master.users.phone_number.notvalid',
defaultMessage: 'Invalid phone number',
}),
},
]}
/>
<ProFormText.Password
rules={[
{
required: true,
message: (
<FormattedMessage
id="master.users.password"
defaultMessage="Password is required"
/>
),
},
{
pattern: /^(?=.*[0-9])(?=.*[!@#$%^&*])[a-zA-Z0-9!@#$%^&*.]{6,16}$/g,
message: intl.formatMessage({
id: 'master.profile.change-password.password.strong',
defaultMessage:
'A password contains at least 8 characters, including at least one number and includes both lower and uppercase letters and special characters, for example #, ?, !',
}),
},
{
validator: async (rule, value) => {
if (value && value.length < 8) {
return Promise.reject(
intl.formatMessage({
id: 'master.users.password.minimum',
defaultMessage: 'Minimum password length is 8',
}),
);
}
return Promise.resolve();
},
},
]}
name="password"
label={intl.formatMessage({
id: 'master.users.password',
defaultMessage: 'Password',
})}
fieldProps={{
prefix: <LockOutlined />,
autoComplete: 'current-password',
}}
placeholder={intl.formatMessage({
id: 'master.users.password.placeholder',
defaultMessage: 'Password',
})}
/>
<ProForm.Item
name="group_id"
label={intl.formatMessage({
id: 'master.users.groups',
defaultMessage: 'Groups',
})}
rules={[
{
required: true,
message: intl.formatMessage({
id: 'master.users.groups.required',
defaultMessage: 'Please select groups!',
}),
},
]}
>
<TreeSelectedGroup groupIds={group_id} onSelected={handleGroupSelect} />
</ProForm.Item>
</ModalForm>
);
};
export default CreateUser;

View File

@@ -0,0 +1,258 @@
import TreeGroup from '@/components/shared/TreeGroup';
import { DEFAULT_PAGE_SIZE } from '@/constants';
import {
apiQueryUsers,
apiQueryUsersByGroup,
} from '@/services/master/UserController';
import {
ActionType,
ProCard,
ProColumns,
ProTable,
} from '@ant-design/pro-components';
import { FormattedMessage, useIntl } from '@umijs/max';
import { Grid } from 'antd';
import message from 'antd/es/message';
import Paragraph from 'antd/lib/typography/Paragraph';
import { useRef, useState } from 'react';
import CreateUser from './components/CreateUser';
const ManagerUserPage = () => {
const { useBreakpoint } = Grid;
const intl = useIntl();
const screens = useBreakpoint();
const actionRef = useRef<ActionType | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [messageApi, contextHolder] = message.useMessage();
const [selectedRowsState, setSelectedRowsState] = useState<
MasterModel.ProfileResponse[]
>([]);
const [groupCheckedKeys, setGroupCheckedKeys] = useState<
string | string[] | null
>(null);
const columns: ProColumns<MasterModel.ProfileResponse>[] = [
{
key: 'email',
title: (
<FormattedMessage id="master.users.email" defaultMessage="Email" />
),
tip: intl.formatMessage({
id: 'master.users.email.tip',
defaultMessage: 'The email is the unique key',
}),
dataIndex: 'email',
render: (_, record) => (
<Paragraph
style={{
marginBottom: 0,
verticalAlign: 'middle',
display: 'inline-block',
}}
copyable
>
{record?.email}
</Paragraph>
),
},
{
key: 'phone_number',
title: (
<FormattedMessage
id="master.users.phone_number"
defaultMessage="Phone number"
/>
),
tip: intl.formatMessage({
id: 'master.users.phone_number.tip',
defaultMessage: 'The phone number is the unique key',
}),
responsive: ['lg', 'md'],
dataIndex: ['metadata', 'phone_number'],
render: (_, record) =>
record?.metadata?.phone_number ? (
<Paragraph
style={{
marginBottom: 0,
verticalAlign: 'middle',
display: 'inline-block',
}}
copyable
>
{record?.metadata?.phone_number}
</Paragraph>
) : (
'-'
),
},
{
key: 'user_type',
hideInSearch: true,
title: <FormattedMessage id="master.users.role" defaultMessage="Role" />,
tip: intl.formatMessage({
id: 'master.users.role.tip',
defaultMessage: 'The role is the unique key',
}),
dataIndex: ['metadata', 'user_type'],
render: (_, record) => record?.metadata?.user_type || '...',
},
{
title: (
<FormattedMessage id="common.actions" defaultMessage="Operating" />
),
hideInSearch: true,
render: () => {
return (
<>
{/* <PermissionButton
user={record}
title={intl.formatMessage({
id: 'master.users.assign',
defaultMessage: 'Assgin',
})}
/> */}
</>
);
},
},
];
return (
<>
{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.ProfileResponse>
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.ProfileResponse[],
) => {
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.users.table.pagination',
defaultMessage: 'users',
})}`,
}}
request={async (params = {}) => {
const { current = 1, pageSize, email, phone_number } = params;
const size = pageSize || DEFAULT_PAGE_SIZE * 2;
const offset = current === 1 ? 0 : (current - 1) * size;
setIsLoading(true);
// If groups are checked, use queryUsersByGroup
if (groupCheckedKeys && groupCheckedKeys.length > 0) {
// Ensure groupCheckedKeys is an array
const groupIdsArray = Array.isArray(groupCheckedKeys)
? groupCheckedKeys.join(',')
: groupCheckedKeys;
const userByGroupResponses = await apiQueryUsersByGroup(
groupIdsArray,
);
let users = userByGroupResponses.users || [];
// Apply filters
if (email) {
users = users.filter((user: MasterModel.ProfileResponse) =>
user.email?.includes(email),
);
}
if (phone_number) {
users = users.filter((user: MasterModel.ProfileResponse) =>
user.metadata?.phone_number?.includes(phone_number),
);
}
const total = users.length;
const paginatedUsers = users.slice(offset, offset + size);
setIsLoading(false);
return {
data: paginatedUsers,
success: true,
total: total,
};
} else {
// Use regular queryUsers API
const metadata: Partial<MasterModel.ProfileMetadata> = {};
if (phone_number) metadata.phone_number = phone_number;
const query: MasterModel.SearchUserPaginationBody = {
offset: offset,
limit: size,
order: 'name',
dir: 'asc',
};
if (email) query.email = email;
if (Object.keys(metadata).length > 0) query.metadata = metadata;
const response = await apiQueryUsers(query);
setIsLoading(false);
return {
data: response.users,
success: true,
total: response.total,
};
}
}}
options={{
search: false,
setting: false,
density: false,
reload: true,
}}
toolBarRender={() => [
<CreateUser
message={messageApi}
onSuccess={(isSuccess) => {
if (isSuccess) {
actionRef.current?.reload();
}
}}
key="create-user"
/>,
]}
/>
</ProCard>
</ProCard>
</>
);
};
export default ManagerUserPage;