2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-10-31 05:05:42 +00:00

feat(frontend): Add start page with quick actions to Admin Center (#7995)

* add option to set leftMargin

* Add home tab and action button

* make home button actually go to home

* Add general info text

* Add dependeant quick action section

* Add Quickaction to home page

* use Carousel

* style check

* small fixes

* add permanent alerts to Admin Center Home

* also show inactive alerts

* fix order of alerts

* simplify attrs

* remove security section for now

* bring quick actions alive

* adjust text

* Use StylishText

* Make alert columns reactive

* Adjust text formatting

* Refactor <QuickActions />

- Use responsive grid instead of carousel
- Add icons
- Translate text

---------

Co-authored-by: Oliver Walters <oliver.henry.walters@gmail.com>
This commit is contained in:
Matthias Mair
2025-10-30 12:06:07 +01:00
committed by GitHub
parent 62440893c1
commit 1159418b17
6 changed files with 386 additions and 96 deletions

View File

@@ -2,12 +2,14 @@ import { ActionIcon, Alert, Group, Menu, Stack, Tooltip } from '@mantine/core';
import { IconExclamationCircle } from '@tabler/icons-react'; import { IconExclamationCircle } from '@tabler/icons-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { SettingsStateProps } from '@lib/types/Settings';
import { t } from '@lingui/core/macro'; import { t } from '@lingui/core/macro';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { docLinks } from '../../defaults/links'; import { docLinks } from '../../defaults/links';
import { useServerApiState } from '../../states/ServerApiState'; import { useServerApiState } from '../../states/ServerApiState';
import { useGlobalSettingsState } from '../../states/SettingsStates'; import { useGlobalSettingsState } from '../../states/SettingsStates';
import { useUserState } from '../../states/UserState'; import { useUserState } from '../../states/UserState';
import type { ServerAPIProps } from '../../states/states';
interface AlertInfo { interface AlertInfo {
key: string; key: string;
@@ -32,64 +34,21 @@ export function Alerts() {
const [dismissed, setDismissed] = useState<string[]>([]); const [dismissed, setDismissed] = useState<string[]>([]);
const alerts: AlertInfo[] = useMemo(() => { const alerts: AlertInfo[] = useMemo(
const _alerts: AlertInfo[] = []; () =>
getAlerts(server, globalSettings).filter(
if (server?.debug_mode) { (alert) => !dismissed.includes(alert.key)
_alerts.push({ ),
key: 'debug', [server, dismissed, globalSettings]
title: t`Debug Mode`, );
code: 'INVE-W4',
message: t`The server is running in debug mode.`
});
}
if (!server?.worker_running) {
_alerts.push({
key: 'worker',
title: t`Background Worker`,
code: 'INVE-W5',
message: t`The background worker process is not running.`
});
}
if (!server?.email_configured) {
_alerts.push({
key: 'email',
title: t`Email settings`,
code: 'INVE-W7',
message: t`Email settings not configured.`
});
}
if (globalSettings.isSet('SERVER_RESTART_REQUIRED')) {
_alerts.push({
key: 'restart',
title: t`Server Restart`,
code: 'INVE-W6',
message: t`The server requires a restart to apply changes.`
});
}
const n_migrations =
Number.parseInt(globalSettings.getSetting('_PENDING_MIGRATIONS')) ?? 0;
if (n_migrations > 0) {
_alerts.push({
key: 'migrations',
title: t`Database Migrations`,
code: 'INVE-W8',
message: t`There are pending database migrations.`
});
}
return _alerts.filter((alert) => !dismissed.includes(alert.key));
}, [server, dismissed, globalSettings]);
const anyErrors: boolean = useMemo( const anyErrors: boolean = useMemo(
() => alerts.some((alert) => alert.error), () => alerts.some((alert) => alert.error),
[alerts] [alerts]
); );
function closeAlert(key: string) {
setDismissed([...dismissed, key]);
}
if (user.isStaff() && alerts.length > 0) if (user.isStaff() && alerts.length > 0)
return ( return (
@@ -108,22 +67,7 @@ export function Alerts() {
<Menu.Dropdown> <Menu.Dropdown>
{alerts.map((alert) => ( {alerts.map((alert) => (
<Menu.Item key={`alert-item-${alert.key}`}> <Menu.Item key={`alert-item-${alert.key}`}>
<Alert <ServerAlert alert={alert} closeAlert={closeAlert} />
withCloseButton
color={alert.error ? 'red' : 'orange'}
title={
<Group gap='xs'>
{alert.code && `${alert.code}: `}
{alert.title}
</Group>
}
onClose={() => setDismissed([...dismissed, alert.key])}
>
<Stack gap='xs'>
{alert.message}
{alert.code && errorCodeLink(alert.code)}
</Stack>
</Alert>
</Menu.Item> </Menu.Item>
))} ))}
</Menu.Dropdown> </Menu.Dropdown>
@@ -131,6 +75,84 @@ export function Alerts() {
); );
return null; return null;
} }
export function ServerAlert({
alert,
closeAlert
}: { alert: AlertInfo; closeAlert?: (key: string) => void }) {
return (
<Alert
withCloseButton={!!closeAlert}
color={alert.error ? 'red' : 'orange'}
title={
<Group gap='xs'>
{alert.code && `${alert.code}: `}
{alert.title}
</Group>
}
onClose={closeAlert ? () => closeAlert(alert.key) : undefined}
>
<Stack gap='xs'>
{alert.message}
{alert.code && errorCodeLink(alert.code)}
</Stack>
</Alert>
);
}
type ExtendedAlertInfo = AlertInfo & {
condition: boolean;
};
export function getAlerts(
server: ServerAPIProps,
globalSettings: SettingsStateProps,
inactive = false
): ExtendedAlertInfo[] {
const n_migrations =
Number.parseInt(globalSettings.getSetting('_PENDING_MIGRATIONS')) ?? 0;
const allalerts: ExtendedAlertInfo[] = [
{
key: 'debug',
title: t`Debug Mode`,
code: 'INVE-W4',
message: t`The server is running in debug mode.`,
condition: server?.debug_mode || false
},
{
key: 'worker',
title: t`Background Worker`,
code: 'INVE-W5',
message: t`The background worker process is not running.`,
condition: !server?.worker_running
},
{
key: 'restart',
title: t`Server Restart`,
code: 'INVE-W6',
message: t`The server requires a restart to apply changes.`,
condition: globalSettings.isSet('SERVER_RESTART_REQUIRED')
},
{
key: 'email',
title: t`Email settings`,
code: 'INVE-W7',
message: t`Email settings not configured.`,
condition: !server?.email_configured
},
{
key: 'migrations',
title: t`Database Migrations`,
code: 'INVE-W8',
message: t`There are pending database migrations.`,
condition: n_migrations > 0
}
];
return allalerts.filter((alert) => inactive || alert.condition);
}
export function errorCodeLink(code: string) { export function errorCodeLink(code: string) {
return ( return (
<a <a

View File

@@ -0,0 +1,138 @@
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Button, Group, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
import {
IconBrandGithub,
IconListCheck,
IconUserPlus,
IconUsersGroup,
type ReactNode
} from '@tabler/icons-react';
import { ApiEndpoints } from '@lib/index';
import {
projectCodeFields,
useCustomStateFields
} from '../../forms/CommonForms';
import { useCreateApiFormModal } from '../../hooks/UseForm';
import { groupFields } from '../../tables/settings/GroupTable';
import { userFields } from '../../tables/settings/UserTable';
interface ActionItem {
id: string;
title: string;
description: string;
icon?: ReactNode;
buttonText?: string;
action: () => void;
}
function ActionGrid({ items }: { items: ActionItem[] }) {
const slides = items.map((image) => (
<Paper shadow='xs' p='sm' withBorder>
<Group justify='space-between' wrap='nowrap'>
<Stack>
<Text>
<strong>{image.title}</strong>
<br />
{image.description}
</Text>
</Stack>
<Button
size='sm'
variant='light'
onClick={image.action}
leftSection={image.icon}
>
{image.buttonText ?? <Trans>Act</Trans>}
</Button>
</Group>
</Paper>
));
return (
<SimpleGrid
cols={{
base: 1,
'600px': 2,
'1200px': 3
}}
type='container'
spacing='sm'
>
{slides}
</SimpleGrid>
);
}
export const QuickAction = () => {
const newUser = useCreateApiFormModal(userFields());
const newGroup = useCreateApiFormModal(groupFields());
const newProjectCode = useCreateApiFormModal({
url: ApiEndpoints.project_code_list,
title: t`Add Project Code`,
fields: projectCodeFields()
});
const newCustomState = useCreateApiFormModal({
url: ApiEndpoints.custom_state_list,
title: t`Add State`,
fields: useCustomStateFields()
});
const items = [
{
id: '0',
title: t`Open an Issue`,
description: t`Report a bug or request a feature on GitHub`,
icon: <IconBrandGithub />,
buttonText: t`Open Issue`,
action: () =>
window.open(
'https://github.com/inventree/inventree/issues/new',
'_blank'
)
},
{
id: '1',
title: t`Add New Group`,
description: t`Create a new group to manage your users`,
icon: <IconUsersGroup />,
buttonText: t`New Group`,
action: () => newGroup.open()
},
{
id: '2',
title: t`Add New User`,
description: t`Create a new user to manage your groups`,
icon: <IconUserPlus />,
buttonText: t`New User`,
action: () => newUser.open()
},
{
id: '3',
title: t`Add Project Code`,
description: t`Create a new project code to organize your items`,
icon: <IconListCheck />,
buttonText: t`Add Code`,
action: () => newProjectCode.open()
},
{
id: '4',
title: t`Add Custom State`,
description: t`Create a new custom state for your workflow`,
icon: <IconListCheck />,
buttonText: t`Add State`,
action: () => newCustomState.open()
}
];
return (
<Stack gap={'xs'} ml={'sm'}>
<ActionGrid items={items} />
{newUser.modal}
{newGroup.modal}
{newProjectCode.modal}
{newCustomState.modal}
</Stack>
);
};

View File

@@ -0,0 +1,111 @@
import { t } from '@lingui/core/macro';
import { Trans } from '@lingui/react/macro';
import { Accordion, Alert, SimpleGrid, Stack, Text } from '@mantine/core';
import { type JSX, useMemo, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { StylishText } from '../../../../components/items/StylishText';
import { ServerAlert, getAlerts } from '../../../../components/nav/Alerts';
import { QuickAction } from '../../../../components/settings/QuickAction';
import { useServerApiState } from '../../../../states/ServerApiState';
import { useGlobalSettingsState } from '../../../../states/SettingsStates';
export default function HomePanel(): JSX.Element {
const [dismissed, setDismissed] = useState<boolean>(false);
const [server] = useServerApiState(useShallow((state) => [state.server]));
const globalSettings = useGlobalSettingsState();
const accElements = useMemo(() => {
const _alerts = getAlerts(server, globalSettings, true);
return [
{
key: 'active',
text: t`Active Alerts`,
elements: _alerts.filter((alert) => alert.condition)
},
{
key: 'inactive',
text: t`Inactive Alerts`,
elements: _alerts.filter((alert) => !alert.condition)
}
];
}, [server, globalSettings]);
return (
<Stack gap='xs'>
{dismissed ? null : (
<Alert
color='blue'
title={t`Admin Center Information`}
withCloseButton
onClose={() => setDismissed(true)}
>
<Stack gap='xs'>
<Text>
<Trans>
The home panel (and the whole Admin Center) is a new feature
starting with the new UI and was previously (before 1.0) not
available.
</Trans>
</Text>
<Text>
<Trans>
The admin center provides a centralized location for all
administration functionality and is meant to replace all
interaction with the (django) backend admin interface.
</Trans>
</Text>
<Text>
<Trans>
Please open feature requests (after checking the tracker) for
any existing backend admin functionality you are missing in this
UI. The backend admin interface should be used carefully and
seldom.
</Trans>
</Text>
</Stack>
</Alert>
)}
<Accordion
multiple
defaultValue={['quick-actions', 'active']}
variant='contained'
>
<Accordion.Item value='quick-actions'>
<Accordion.Control>
<StylishText size='md'>
<Trans>Quick Actions</Trans>
</StylishText>
</Accordion.Control>
<Accordion.Panel>
<QuickAction />
</Accordion.Panel>
</Accordion.Item>
{accElements.map(
(item) =>
item.elements.length > 0 && (
<Accordion.Item key={item.key} value={item.key}>
<Accordion.Control>
<StylishText size='md'>{item.text}</StylishText>
</Accordion.Control>
<Accordion.Panel>
<SimpleGrid
cols={{
base: 1,
'600px': 2,
'1200px': 3
}}
type='container'
spacing='sm'
>
{item.elements.map((alert) => (
<ServerAlert alert={alert} />
))}
</SimpleGrid>
</Accordion.Panel>
</Accordion.Item>
)
)}
</Accordion>
</Stack>
);
}

View File

@@ -7,6 +7,7 @@ import {
IconExclamationCircle, IconExclamationCircle,
IconFileDownload, IconFileDownload,
IconFileUpload, IconFileUpload,
IconHome,
IconList, IconList,
IconListDetails, IconListDetails,
IconMail, IconMail,
@@ -40,6 +41,8 @@ const ReportTemplatePanel = Loadable(
const LabelTemplatePanel = Loadable(lazy(() => import('./LabelTemplatePanel'))); const LabelTemplatePanel = Loadable(lazy(() => import('./LabelTemplatePanel')));
const HomePanel = Loadable(lazy(() => import('./HomePanel')));
const UserManagementPanel = Loadable( const UserManagementPanel = Loadable(
lazy(() => import('./UserManagementPanel')) lazy(() => import('./UserManagementPanel'))
); );
@@ -107,6 +110,13 @@ export default function AdminCenter() {
const adminCenterPanels: PanelType[] = useMemo(() => { const adminCenterPanels: PanelType[] = useMemo(() => {
return [ return [
{
name: 'home',
label: t`Home`,
icon: <IconHome />,
content: <HomePanel />,
showHeadline: false
},
{ {
name: 'user', name: 'user',
label: t`Users / Access`, label: t`Users / Access`,
@@ -231,6 +241,7 @@ export default function AdminCenter() {
}, [user]); }, [user]);
const grouping: PanelGroupType[] = useMemo(() => { const grouping: PanelGroupType[] = useMemo(() => {
return [ return [
{ id: 'home', label: '', panelIDs: ['home'] },
{ {
id: 'ops', id: 'ops',
label: t`Operations`, label: t`Operations`,

View File

@@ -13,8 +13,8 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/index'; import { type ApiFormModalProps, getDetailUrl } from '@lib/index';
import type { TableColumn } from '@lib/types/Tables'; import type { TableColumn, TableState } from '@lib/types/Tables';
import { IconUsersGroup } from '@tabler/icons-react'; import { IconUsersGroup } from '@tabler/icons-react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { EditApiForm } from '../../components/forms/ApiForm'; import { EditApiForm } from '../../components/forms/ApiForm';
@@ -188,17 +188,7 @@ export function GroupTable({
preFormWarning: t`Are you sure you want to delete this group?` preFormWarning: t`Are you sure you want to delete this group?`
}); });
const newGroup = useCreateApiFormModal({ const newGroup = useCreateApiFormModal(groupFields(table));
url: ApiEndpoints.group_list,
title: t`Add Group`,
fields: {
name: {
label: t`Name`,
description: t`Name of the user group`
}
},
table: table
});
const tableActions = useMemo(() => { const tableActions = useMemo(() => {
const actions = []; const actions = [];
@@ -256,3 +246,17 @@ export function GroupTable({
</> </>
); );
} }
export function groupFields(table?: TableState): ApiFormModalProps {
return {
url: ApiEndpoints.group_list,
title: t`Add Group`,
fields: {
name: {
label: t`Name`,
description: t`Name of the user group`
}
},
table: table ?? undefined
};
}

View File

@@ -20,9 +20,9 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType'; import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles'; import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api'; import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/index'; import { type ApiFormModalProps, getDetailUrl } from '@lib/index';
import type { TableFilter } from '@lib/types/Filters'; import type { TableFilter } from '@lib/types/Filters';
import type { TableColumn } from '@lib/types/Tables'; import type { TableColumn, TableState } from '@lib/types/Tables';
import { showNotification } from '@mantine/notifications'; import { showNotification } from '@mantine/notifications';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
@@ -363,18 +363,7 @@ export function UserTable({
}); });
// Table Actions - Add New User // Table Actions - Add New User
const newUser = useCreateApiFormModal({ const newUser = useCreateApiFormModal(userFields(table));
url: ApiEndpoints.user_list,
title: t`Add User`,
fields: {
username: {},
email: {},
first_name: {},
last_name: {}
},
table: table,
successMessage: t`Added user`
});
const setPassword = useApiFormModal({ const setPassword = useApiFormModal({
url: ApiEndpoints.user_set_password, url: ApiEndpoints.user_set_password,
@@ -468,6 +457,21 @@ export function UserTable({
); );
} }
export function userFields(table?: TableState): ApiFormModalProps {
return {
url: ApiEndpoints.user_list,
title: t`Add User`,
fields: {
username: {},
email: {},
first_name: {},
last_name: {}
},
table: table ?? undefined,
successMessage: t`Added user`
};
}
async function setUserActiveState(userId: number, active: boolean) { async function setUserActiveState(userId: number, active: boolean) {
try { try {
await api.patch(apiUrl(ApiEndpoints.user_list, userId), { await api.patch(apiUrl(ApiEndpoints.user_list, userId), {