diff --git a/src/frontend/src/components/forms/ApiForm.tsx b/src/frontend/src/components/forms/ApiForm.tsx index a0670ad1f5..05f1c282f0 100644 --- a/src/frontend/src/components/forms/ApiForm.tsx +++ b/src/frontend/src/components/forms/ApiForm.tsx @@ -43,7 +43,7 @@ import { ApiFormField, ApiFormFieldSet } from './fields/ApiFormField'; export interface ApiFormProps { name: string; url: ApiPaths; - pk?: number | string; + pk?: number | string | undefined; title: string; fields?: ApiFormFieldSet; cancelText?: string; @@ -51,6 +51,7 @@ export interface ApiFormProps { submitColor?: string; cancelColor?: string; fetchInitialData?: boolean; + ignorePermissionCheck?: boolean; method?: string; preFormContent?: JSX.Element | (() => JSX.Element); postFormContent?: JSX.Element | (() => JSX.Element); diff --git a/src/frontend/src/components/forms/fields/RelatedModelField.tsx b/src/frontend/src/components/forms/fields/RelatedModelField.tsx index 2bb346cd67..aa29fc65ae 100644 --- a/src/frontend/src/components/forms/fields/RelatedModelField.tsx +++ b/src/frontend/src/components/forms/fields/RelatedModelField.tsx @@ -3,7 +3,7 @@ import { Input } from '@mantine/core'; import { UseFormReturnType } from '@mantine/form'; import { useDebouncedValue } from '@mantine/hooks'; import { useId } from '@mantine/hooks'; -import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import { ReactNode, useEffect, useMemo, useState } from 'react'; import Select from 'react-select'; diff --git a/src/frontend/src/components/nav/Header.tsx b/src/frontend/src/components/nav/Header.tsx index d9160df35e..10171ec128 100644 --- a/src/frontend/src/components/nav/Header.tsx +++ b/src/frontend/src/components/nav/Header.tsx @@ -75,10 +75,10 @@ export function Header() { - + [ - state.user?.name ?? state.user?.username - ]); + const userState = useUserState(); + return ( - {username ? ( - username + {userState.username() ? ( + userState.username() ) : ( )} @@ -46,6 +46,25 @@ export function MainMenu() { } component={Link} to="/profile/user"> Account settings + } component={Link} to="/settings/user"> + Account settings + + {userState.user?.is_staff && ( + } component={Link} to="/settings/"> + System Settings + + )} + {userState.user?.is_staff && ( + } + component={Link} + to="/settings/plugin" + > + Plugins + + )} + + } onClick={() => { diff --git a/src/frontend/src/components/nav/PanelGroup.tsx b/src/frontend/src/components/nav/PanelGroup.tsx index 58e485c43f..fced385883 100644 --- a/src/frontend/src/components/nav/PanelGroup.tsx +++ b/src/frontend/src/components/nav/PanelGroup.tsx @@ -14,6 +14,7 @@ import { import { ReactNode } from 'react'; import { useEffect, useState } from 'react'; +import { PlaceholderPanel } from '../items/Placeholder'; import { StylishText } from '../items/StylishText'; /** @@ -23,7 +24,7 @@ export type PanelType = { name: string; label: string; icon?: ReactNode; - content: ReactNode; + content?: ReactNode; hidden?: boolean; disabled?: boolean; }; @@ -119,7 +120,7 @@ export function PanelGroup({ {panel.label} - {panel.content} + {panel.content ?? } ) diff --git a/src/frontend/src/components/settings/SettingItem.tsx b/src/frontend/src/components/settings/SettingItem.tsx new file mode 100644 index 0000000000..9811d84d2e --- /dev/null +++ b/src/frontend/src/components/settings/SettingItem.tsx @@ -0,0 +1,147 @@ +import { t } from '@lingui/macro'; +import { Button, Group, Space, Stack, Switch, Text } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { IconEdit } from '@tabler/icons-react'; +import { useMemo } from 'react'; + +import { api } from '../../App'; +import { openModalApiForm } from '../../functions/forms'; +import { ApiPaths, apiUrl } from '../../states/ApiState'; +import { SettingsStateProps } from '../../states/SettingsState'; +import { Setting } from '../../states/states'; + +/** + * Render a single setting value + */ +function SettingValue({ + settingsState, + setting +}: { + settingsState: SettingsStateProps; + setting: Setting; +}) { + // Callback function when a boolean value is changed + function onToggle(value: boolean) { + api + .patch(apiUrl(settingsState.endpoint, setting.key), { value: value }) + .then(() => { + showNotification({ + title: t`Setting updated`, + message: t`${setting?.name} updated successfully`, + color: 'green' + }); + settingsState.fetchSettings(); + }) + .catch((error) => { + console.log('Error editing setting', error); + showNotification({ + title: t`Error editing setting`, + message: error.message, + color: 'red' + }); + }); + } + + // Callback function to open the edit dialog (for non-boolean settings) + function onEditButton() { + let field_type: string = setting?.type ?? 'string'; + + if (setting?.choices && setting?.choices?.length > 0) { + field_type = 'choice'; + } + + openModalApiForm({ + name: 'setting-edit', + url: settingsState.endpoint, + pk: setting.key, + method: 'PATCH', + title: t`Edit Setting`, + ignorePermissionCheck: true, + fields: { + value: { + value: setting?.value ?? '', + field_type: field_type, + choices: setting?.choices || [], + label: setting?.name, + description: setting?.description + } + }, + onFormSuccess() { + showNotification({ + title: t`Setting updated`, + message: t`${setting?.name} updated successfully`, + color: 'green' + }); + settingsState.fetchSettings(); + } + }); + } + + // Determine the text to display for the setting value + const valueText: string = useMemo(() => { + let value = setting.value; + + // If the setting has a choice, display the choice label + if (setting?.choices && setting?.choices?.length > 0) { + const choice = setting.choices.find((c) => c.value == setting.value); + value = choice?.display_name || setting.value; + } + + if (setting?.units) { + value = `${value} ${setting.units}`; + } + + return value; + }, [setting]); + + switch (setting?.type || 'string') { + case 'boolean': + return ( + onToggle(event.currentTarget.checked)} + style={{ + paddingRight: '20px' + }} + /> + ); + default: + return valueText ? ( + + + + + ) : ( + + ); + } +} + +/** + * Display a single setting item, and allow editing of the value + */ +export function SettingItem({ + settingsState, + setting +}: { + settingsState: SettingsStateProps; + setting: Setting; +}) { + return ( + <> + + + {setting.name} + {setting.description} + + + + + ); +} diff --git a/src/frontend/src/components/settings/SettingList.tsx b/src/frontend/src/components/settings/SettingList.tsx new file mode 100644 index 0000000000..7cd1748db2 --- /dev/null +++ b/src/frontend/src/components/settings/SettingList.tsx @@ -0,0 +1,59 @@ +import { LoadingOverlay, Stack, Text } from '@mantine/core'; +import { useEffect } from 'react'; + +import { + SettingsStateProps, + useGlobalSettingsState, + useUserSettingsState +} from '../../states/SettingsState'; +import { SettingItem } from './SettingItem'; + +/** + * Display a list of setting items, based on a list of provided keys + */ +export function SettingList({ + settingsState, + keys +}: { + settingsState: SettingsStateProps; + keys: string[]; +}) { + useEffect(() => { + settingsState.fetchSettings(); + }, []); + + return ( + <> + + {keys.map((key) => { + const setting = settingsState?.settings?.find( + (s: any) => s.key === key + ); + return ( +
+ {setting ? ( + + ) : ( + + Setting {key} not found + + )} +
+ ); + })} +
+ + ); +} + +export function UserSettingList({ keys }: { keys: string[] }) { + const userSettings = useUserSettingsState(); + + return ; +} + +export function GlobalSettingList({ keys }: { keys: string[] }) { + const globalSettings = useGlobalSettingsState(); + + return ; +} diff --git a/src/frontend/src/components/tables/part/PartTable.tsx b/src/frontend/src/components/tables/part/PartTable.tsx index 92abbf5c4e..e28dde4db5 100644 --- a/src/frontend/src/components/tables/part/PartTable.tsx +++ b/src/frontend/src/components/tables/part/PartTable.tsx @@ -188,8 +188,8 @@ function partTableFilters(): TableFilter[] { * @returns */ export function PartListTable({ props }: { props: InvenTreeTableProps }) { - let tableColumns = useMemo(() => partTableColumns(), []); - let tableFilters = useMemo(() => partTableFilters(), []); + const tableColumns = useMemo(() => partTableColumns(), []); + const tableFilters = useMemo(() => partTableFilters(), []); const { tableKey, refreshTable } = useTableRefresh('part'); diff --git a/src/frontend/src/components/tables/plugin/PluginListTable.tsx b/src/frontend/src/components/tables/plugin/PluginListTable.tsx new file mode 100644 index 0000000000..0c171c58b7 --- /dev/null +++ b/src/frontend/src/components/tables/plugin/PluginListTable.tsx @@ -0,0 +1,160 @@ +import { t } from '@lingui/macro'; +import { Group, Text, Tooltip } from '@mantine/core'; +import { + IconCircleCheck, + IconCircleX, + IconHelpCircle +} from '@tabler/icons-react'; +import { useMemo } from 'react'; + +import { notYetImplemented } from '../../../functions/notifications'; +import { useTableRefresh } from '../../../hooks/TableRefresh'; +import { ApiPaths, apiUrl } from '../../../states/ApiState'; +import { TableColumn } from '../Column'; +import { InvenTreeTable, InvenTreeTableProps } from '../InvenTreeTable'; +import { RowAction } from '../RowActions'; + +/** + * Construct an indicator icon for a single plugin + */ +function PluginIcon(plugin: any) { + if (plugin.is_installed) { + if (plugin.active) { + return ( + + + + ); + } else { + return ( + + + + ); + } + } else { + return ( + + + + ); + } +} + +/** + * Table displaying list of available plugins + */ +export function PluginListTable({ props }: { props: InvenTreeTableProps }) { + const { tableKey, refreshTable } = useTableRefresh('plugin'); + + const pluginTableColumns: TableColumn[] = useMemo( + () => [ + { + accessor: 'name', + title: t`Plugin`, + sortable: true, + render: function (record: any) { + // TODO: Add link to plugin detail page + // TODO: Add custom badges + return ( + + + {record.name} + + ); + } + }, + { + accessor: 'meta.description', + title: t`Description`, + sortable: false, + switchable: true, + render: function (record: any) { + if (record.active) { + return record.meta.description; + } else { + return {t`Description not available`}; + } + } + }, + { + accessor: 'meta.version', + title: t`Version`, + sortable: false, + switchable: true + // TODO: Display date information if available + }, + { + accessor: 'meta.author', + title: 'Author', + sortable: false, + switchable: true + } + ], + [] + ); + + // Determine available actions for a given plugin + function rowActions(record: any): RowAction[] { + let actions: RowAction[] = []; + + if (!record.is_builtin && record.is_installed) { + if (record.active) { + actions.push({ + title: t`Deactivate`, + color: 'red', + onClick: () => { + notYetImplemented(); + } + }); + } else { + actions.push({ + title: t`Activate`, + onClick: () => { + notYetImplemented(); + } + }); + } + } + + return actions; + } + + return ( + + ); +} diff --git a/src/frontend/src/components/tables/settings/CustomUnitsTable.tsx b/src/frontend/src/components/tables/settings/CustomUnitsTable.tsx new file mode 100644 index 0000000000..a71aeb21bf --- /dev/null +++ b/src/frontend/src/components/tables/settings/CustomUnitsTable.tsx @@ -0,0 +1,125 @@ +import { t } from '@lingui/macro'; +import { ActionIcon, Text, Tooltip } from '@mantine/core'; +import { IconCirclePlus } from '@tabler/icons-react'; +import { useCallback, useMemo } from 'react'; + +import { + openCreateApiForm, + openDeleteApiForm, + openEditApiForm +} from '../../../functions/forms'; +import { useTableRefresh } from '../../../hooks/TableRefresh'; +import { ApiPaths, apiUrl } from '../../../states/ApiState'; +import { TableColumn } from '../Column'; +import { InvenTreeTable } from '../InvenTreeTable'; +import { RowAction } from '../RowActions'; + +/** + * Table for displaying list of custom physical units + */ +export function CustomUnitsTable() { + const { tableKey, refreshTable } = useTableRefresh('custom-units'); + + const columns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'name', + title: t`Name`, + switchable: false, + sortable: true + }, + { + accessor: 'definition', + title: t`Definition`, + switchable: false, + sortable: false + }, + { + accessor: 'symbol', + title: t`Symbol`, + switchable: false, + sortable: true + } + ]; + }, []); + + const rowActions = useCallback((record: any): RowAction[] => { + return [ + { + title: t`Edit`, + onClick: () => { + openEditApiForm({ + name: 'edit-custom-unit', + url: ApiPaths.custom_unit_list, + pk: record.pk, + title: t`Edit custom unit`, + fields: { + name: {}, + definition: {}, + symbol: {} + }, + onFormSuccess: refreshTable, + successMessage: t`Custom unit updated` + }); + } + }, + { + title: t`Delete`, + onClick: () => { + openDeleteApiForm({ + name: 'delete-custom-unit', + url: ApiPaths.custom_unit_list, + pk: record.pk, + title: t`Delete custom unit`, + successMessage: t`Custom unit deleted`, + onFormSuccess: refreshTable, + preFormContent: ( + {t`Are you sure you want to remove this custom unit?`} + ) + }); + } + } + ]; + }, []); + + const addCustomUnit = useCallback(() => { + openCreateApiForm({ + name: 'add-custom-unit', + url: ApiPaths.custom_unit_list, + title: t`Add custom unit`, + fields: { + name: {}, + definition: {}, + symbol: {} + }, + successMessage: t`Custom unit created`, + onFormSuccess: refreshTable + }); + }, []); + + const tableActions = useMemo(() => { + let actions = []; + + actions.push( + + + + + + ); + + return actions; + }, []); + + return ( + + ); +} diff --git a/src/frontend/src/components/tables/settings/ProjectCodeTable.tsx b/src/frontend/src/components/tables/settings/ProjectCodeTable.tsx new file mode 100644 index 0000000000..1766a2948a --- /dev/null +++ b/src/frontend/src/components/tables/settings/ProjectCodeTable.tsx @@ -0,0 +1,118 @@ +import { t } from '@lingui/macro'; +import { ActionIcon, Text, Tooltip } from '@mantine/core'; +import { showNotification } from '@mantine/notifications'; +import { IconCirclePlus } from '@tabler/icons-react'; +import { useCallback, useMemo } from 'react'; + +import { + openCreateApiForm, + openDeleteApiForm, + openEditApiForm +} from '../../../functions/forms'; +import { notYetImplemented } from '../../../functions/notifications'; +import { useTableRefresh } from '../../../hooks/TableRefresh'; +import { ApiPaths, apiUrl } from '../../../states/ApiState'; +import { TableColumn } from '../Column'; +import { InvenTreeTable } from '../InvenTreeTable'; +import { RowAction } from '../RowActions'; + +/** + * Table for displaying list of project codes + */ +export function ProjectCodeTable() { + const { tableKey, refreshTable } = useTableRefresh('project-code'); + + const columns: TableColumn[] = useMemo(() => { + return [ + { + accessor: 'code', + sortable: true, + title: t`Project Code` + }, + { + accessor: 'description', + sortable: false, + title: t`Description` + } + ]; + }, []); + + const rowActions = useCallback((record: any): RowAction[] => { + return [ + { + title: t`Edit`, + onClick: () => { + openEditApiForm({ + name: 'edit-project-code', + url: ApiPaths.project_code_list, + pk: record.pk, + title: t`Edit project code`, + fields: { + code: {}, + description: {} + }, + onFormSuccess: refreshTable, + successMessage: t`Project code updated` + }); + } + }, + { + title: t`Delete`, + color: 'red', + onClick: () => { + openDeleteApiForm({ + name: 'delete-project-code', + url: ApiPaths.project_code_list, + pk: record.pk, + title: t`Delete project code`, + successMessage: t`Project code deleted`, + onFormSuccess: refreshTable, + preFormContent: ( + {t`Are you sure you want to remove this project code?`} + ) + }); + } + } + ]; + }, []); + + const addProjectCode = useCallback(() => { + openCreateApiForm({ + name: 'add-project-code', + url: ApiPaths.project_code_list, + title: t`Add project code`, + fields: { + code: {}, + description: {} + }, + onFormSuccess: refreshTable, + successMessage: t`Added project code` + }); + }, []); + + const tableActions = useMemo(() => { + let actions = []; + + actions.push( + + + + + + ); + + return actions; + }, []); + + return ( + + ); +} diff --git a/src/frontend/src/contexts/BaseContext.tsx b/src/frontend/src/contexts/BaseContext.tsx index 4125dcf382..fc568b878f 100644 --- a/src/frontend/src/contexts/BaseContext.tsx +++ b/src/frontend/src/contexts/BaseContext.tsx @@ -1,10 +1,15 @@ +import { QueryClientProvider } from '@tanstack/react-query'; + +import { queryClient } from '../App'; import { LanguageContext } from './LanguageContext'; import { ThemeContext } from './ThemeContext'; export const BaseContext = ({ children }: { children: any }) => { return ( - - {children} - + + + {children} + + ); }; diff --git a/src/frontend/src/contexts/ThemeContext.tsx b/src/frontend/src/contexts/ThemeContext.tsx index 1864b44c00..66db20e48d 100644 --- a/src/frontend/src/contexts/ThemeContext.tsx +++ b/src/frontend/src/contexts/ThemeContext.tsx @@ -8,9 +8,7 @@ import { import { useColorScheme, useLocalStorage } from '@mantine/hooks'; import { ModalsProvider } from '@mantine/modals'; import { Notifications } from '@mantine/notifications'; -import { QueryClientProvider } from '@tanstack/react-query'; -import { queryClient } from '../App'; import { QrCodeModal } from '../components/modals/QrCodeModal'; import { useLocalState } from '../states/LocalState'; @@ -60,14 +58,12 @@ export function ThemeContext({ children }: { children: JSX.Element }) { > - - - {children} - - + + {children} + ); diff --git a/src/frontend/src/functions/forms.tsx b/src/frontend/src/functions/forms.tsx index be32260935..577cbbc014 100644 --- a/src/frontend/src/functions/forms.tsx +++ b/src/frontend/src/functions/forms.tsx @@ -98,11 +98,15 @@ export function openModalApiForm(props: ApiFormProps) { .options(url) .then((response) => { // Extract available fields from the OPTIONS response (and handle any errors) - let fields: Record | null = - extractAvailableFields(response, props.method); - if (fields == null) { - return; + let fields: Record | null = {}; + + if (!props.ignorePermissionCheck) { + fields = extractAvailableFields(response, props.method); + + if (fields == null) { + return; + } } // Generate a random modal ID for controller diff --git a/src/frontend/src/pages/Index/Home.tsx b/src/frontend/src/pages/Index/Home.tsx index f1ba84888f..67fecb7f2c 100644 --- a/src/frontend/src/pages/Index/Home.tsx +++ b/src/frontend/src/pages/Index/Home.tsx @@ -51,7 +51,7 @@ const vals: LayoutItemType[] = [ ]; export default function Home() { - const [username] = useUserState((state) => [state.user?.name]); + const [username] = useUserState((state) => [state.username()]); return ( <> diff --git a/src/frontend/src/pages/Index/PluginSettings.tsx b/src/frontend/src/pages/Index/PluginSettings.tsx new file mode 100644 index 0000000000..0290aa1d8b --- /dev/null +++ b/src/frontend/src/pages/Index/PluginSettings.tsx @@ -0,0 +1,52 @@ +import { t } from '@lingui/macro'; +import { LoadingOverlay, Stack } from '@mantine/core'; +import { IconPlugConnected } from '@tabler/icons-react'; +import { useMemo } from 'react'; + +import { PageDetail } from '../../components/nav/PageDetail'; +import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; +import { PluginListTable } from '../../components/tables/plugin/PluginListTable'; +import { useInstance } from '../../hooks/UseInstance'; +import { ApiPaths, apiUrl } from '../../states/ApiState'; + +/** + * Plugins settings page + */ +export default function PluginSettings() { + // Query manager for global plugin settings + const { + instance: settings, + refreshInstance: reloadSettings, + instanceQuery: settingsQuery + } = useInstance({ + endpoint: ApiPaths.settings_global_list, + hasPrimaryKey: false, + refetchOnMount: true, + defaultValue: [] + }); + + const pluginPanels: PanelType[] = useMemo(() => { + return [ + { + name: 'plugins', + label: t`Plugins`, + icon: <IconPlugConnected />, + content: ( + <Stack spacing="xs"> + <PluginListTable props={{}} /> + </Stack> + ) + } + ]; + }, []); + + return ( + <> + <Stack spacing="xs"> + <LoadingOverlay visible={settingsQuery.isFetching} /> + <PageDetail title={t`Plugin Settings`} /> + <PanelGroup pageKey="plugin-settings" panels={pluginPanels} /> + </Stack> + </> + ); +} diff --git a/src/frontend/src/pages/Index/SystemSettings.tsx b/src/frontend/src/pages/Index/SystemSettings.tsx new file mode 100644 index 0000000000..4428e63592 --- /dev/null +++ b/src/frontend/src/pages/Index/SystemSettings.tsx @@ -0,0 +1,261 @@ +import { t } from '@lingui/macro'; +import { Divider, Stack } from '@mantine/core'; +import { + IconBellCog, + IconCategory, + IconClipboardCheck, + IconCurrencyDollar, + IconFileAnalytics, + IconFingerprint, + IconList, + IconListDetails, + IconPackages, + IconQrcode, + IconScale, + IconServerCog, + IconShoppingCart, + IconSitemap, + IconTag, + IconTools, + IconTruckDelivery +} from '@tabler/icons-react'; +import { useMemo } from 'react'; + +import { PageDetail } from '../../components/nav/PageDetail'; +import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; +import { GlobalSettingList } from '../../components/settings/SettingList'; +import { CustomUnitsTable } from '../../components/tables/settings/CustomUnitsTable'; +import { ProjectCodeTable } from '../../components/tables/settings/ProjectCodeTable'; + +/** + * System settings page + */ +export default function SystemSettings() { + const systemSettingsPanels: PanelType[] = useMemo(() => { + return [ + { + name: 'server', + label: t`Server`, + icon: <IconServerCog />, + content: ( + <GlobalSettingList + keys={[ + 'INVENTREE_BASE_URL', + 'INVENTREE_COMPANY_NAME', + 'INVENTREE_INSTANCE', + 'INVENTREE_INSTANCE_TITLE', + 'INVENTREE_RESTRICT_ABOUT', + 'INVENTREE_UPDATE_CHECK_INTERVAL', + 'INVENTREE_DOWNLOAD_FROM_URL', + 'INVENTREE_DOWNLOAD_IMAGE_MAX_SIZE', + 'INVENTREE_DOWNLOAD_FROM_URL_USER_AGENT', + 'INVENTREE_REQUIRE_CONFIRM', + 'INVENTREE_TREE_DEPTH', + 'INVENTREE_BACKUP_ENABLE', + 'INVENTREE_BACKUP_DAYS', + 'INVENTREE_DELETE_TASKS_DAYS', + 'INVENTREE_DELETE_ERRORS_DAYS', + 'INVENTREE_DELETE_NOTIFICATIONS_DAYS' + ]} + /> + ) + }, + { + name: 'login', + label: t`Login`, + icon: <IconFingerprint />, + content: ( + <GlobalSettingList + keys={[ + 'LOGIN_ENABLE_PWD_FORGOT', + 'LOGIN_MAIL_REQUIRED', + 'LOGIN_ENFORCE_MFA', + 'LOGIN_ENABLE_REG', + 'LOGIN_SIGNUP_MAIL_TWICE', + 'LOGIN_SIGNUP_PWD_TWICE', + 'SIGNUP_GROUP', + 'LOGIN_SIGNUP_MAIL_RESTRICTION', + 'LOGIN_ENABLE_SSO', + 'LOGIN_ENABLE_SSO_REG', + 'LOGIN_SIGNUP_SSO_AUTO' + ]} + /> + ) + }, + { + name: 'barcode', + label: t`Barcodes`, + icon: <IconQrcode />, + content: ( + <GlobalSettingList + keys={[ + 'BARCODE_ENABLE', + 'BARCODE_INPUT_DELAY', + 'BARCODE_WEBCAM_SUPPORT' + ]} + /> + ) + }, + { + name: 'projectcodes', + label: t`Project Codes`, + icon: <IconListDetails />, + content: ( + <Stack spacing="xs"> + <GlobalSettingList keys={['PROJECT_CODES_ENABLED']} /> + <Divider /> + <ProjectCodeTable /> + </Stack> + ) + }, + { + name: 'physicalunits', + label: t`Physical Units`, + icon: <IconScale />, + content: <CustomUnitsTable /> + }, + { + name: 'notifications', + label: t`Notifications`, + icon: <IconBellCog /> + }, + { + name: 'pricing', + label: t`Pricing`, + icon: <IconCurrencyDollar /> + }, + { + name: 'labels', + label: t`Labels`, + icon: <IconTag />, + content: <GlobalSettingList keys={['LABEL_ENABLE', 'LABEL_DPI']} /> + }, + { + name: 'reporting', + label: t`Reporting`, + icon: <IconFileAnalytics />, + content: ( + <GlobalSettingList + keys={[ + 'REPORT_ENABLE', + 'REPORT_DEFAULT_PAGE_SIZE', + 'REPORT_DEBUG_MODE', + 'REPORT_ENABLE_TEST_REPORT', + 'REPORT_ATTACH_TEST_REPORT' + ]} + /> + ) + }, + { + name: 'categories', + label: t`Part Categories`, + icon: <IconSitemap /> + }, + { + name: 'parts', + label: t`Parts`, + icon: <IconCategory />, + content: ( + <GlobalSettingList + keys={[ + 'PART_ENABLE_REVISION', + 'PART_IPN_REGEX', + 'PART_ALLOW_DUPLICATE_IPN', + 'PART_ALLOW_EDIT_IPN', + 'PART_NAME_FORMAT', + 'PART_SHOW_RELATED', + 'PART_CREATE_INITIAL', + 'PART_CREATE_SUPPLIER', // TODO: Break here + 'PART_TEMPLATE', + 'PART_ASSEMBLY', + 'PART_COMPONENT', + 'PART_TRACKABLE', + 'PART_PURCHASEABLE', + 'PART_SALABLE', + 'PART_VIRTUAL', // TODO: Break here + 'PART_COPY_BOM', + 'PART_COPY_PARAMETERS', + 'PART_COPY_TESTS', + 'PART_CATEGORY_PARAMETERS', + 'PART_CATEGORY_DEFAULT_ICON' // TODO: Move to part category settings page + ]} + /> + ) + }, + { + name: 'parameters', + label: t`Part Parameters`, + icon: <IconList /> + }, + { + name: 'stock', + label: t`Stock`, + icon: <IconPackages />, + content: ( + <GlobalSettingList + keys={[ + 'SERIAL_NUMBER_GLOBALLY_UNIQUE', + 'SERIAL_NUMBER_AUTOFILL', + 'STOCK_DELETE_DEPLETED_DEFAULT', + 'STOCK_BATCH_CODE_TEMPLATE', + 'STOCK_ENABLE_EXPIRY', + 'STOCK_STALE_DAYS', + 'STOCK_ALLOW_EXPIRED_SALE', + 'STOCK_ALLOW_EXPIRED_BUILD', + 'STOCK_OWNERSHIP_CONTROL', + 'STOCK_LOCATION_DEFAULT_ICON', + 'STOCK_SHOW_INSTALLED_ITEMS' + ]} + /> + ) + }, + { + name: 'stocktake', + label: t`Stocktake`, + icon: <IconClipboardCheck /> + }, + { + name: 'buildorders', + label: t`Build Orders`, + icon: <IconTools />, + content: <GlobalSettingList keys={['BUILDORDER_REFERENCE_PATTERN']} /> + }, + { + name: 'purchaseorders', + label: t`Purchase Orders`, + icon: <IconShoppingCart />, + content: ( + <GlobalSettingList + keys={[ + 'PURCHASEORDER_REFERENCE_PATTERN', + 'PURCHASEORDER_EDIT_COMPLETED_ORDERS' + ]} + /> + ) + }, + { + name: 'salesorders', + label: t`Sales Orders`, + icon: <IconTruckDelivery />, + content: ( + <GlobalSettingList + keys={[ + 'SALESORDER_REFERENCE_PATTERN', + 'SALESORDER_DEFAULT_SHIPMENT', + 'SALESORDER_EDIT_COMPLETED_ORDERS' + ]} + /> + ) + } + ]; + }, []); + + return ( + <> + <Stack spacing="xs"> + <PageDetail title={t`System Settings`} /> + <PanelGroup pageKey="system-settings" panels={systemSettingsPanels} /> + </Stack> + </> + ); +} diff --git a/src/frontend/src/pages/Index/UserSettings.tsx b/src/frontend/src/pages/Index/UserSettings.tsx new file mode 100644 index 0000000000..5042f24a3d --- /dev/null +++ b/src/frontend/src/pages/Index/UserSettings.tsx @@ -0,0 +1,110 @@ +import { t } from '@lingui/macro'; +import { Stack, Text } from '@mantine/core'; +import { + IconBellCog, + IconDeviceDesktop, + IconDeviceDesktopAnalytics, + IconFileAnalytics, + IconSearch, + IconUserCircle +} from '@tabler/icons-react'; +import { useMemo } from 'react'; + +import { PageDetail } from '../../components/nav/PageDetail'; +import { PanelGroup, PanelType } from '../../components/nav/PanelGroup'; +import { UserSettingList } from '../../components/settings/SettingList'; + +/** + * User settings page + */ +export default function UserSettings() { + const userSettingsPanels: PanelType[] = useMemo(() => { + return [ + { + name: 'account', + label: t`Account`, + icon: <IconUserCircle /> + }, + { + name: 'dashboard', + label: t`Dashboard`, + icon: <IconDeviceDesktopAnalytics /> + }, + { + name: 'display', + label: t`Display Options`, + icon: <IconDeviceDesktop />, + content: ( + <UserSettingList + keys={[ + 'STICKY_HEADER', + 'DATE_DISPLAY_FORMAT', + 'FORMS_CLOSE_USING_ESCAPE', + 'PART_SHOW_QUANTITY_IN_FORMS', + 'DISPLAY_SCHEDULE_TAB', + 'DISPLAY_STOCKTAKE_TAB', + 'TABLE_STRING_MAX_LENGTH' + ]} + /> + ) + }, + { + name: 'search', + label: t`Search`, + icon: <IconSearch />, + content: ( + <UserSettingList + keys={[ + 'SEARCH_WHOLE', + 'SEARCH_REGEX', + 'SEARCH_PREVIEW_RESULTS', + 'SEARCH_PREVIEW_SHOW_PARTS', + 'SEARCH_HIDE_INACTIVE_PARTS', + 'SEARCH_PREVIEW_SHOW_SUPPLIER_PARTS', + 'SEARCH_PREVIEW_SHOW_MANUFACTURER_PARTS', + 'SEARCH_PREVIEW_SHOW_CATEGORIES', + 'SEARCH_PREVIEW_SHOW_STOCK', + 'SEARCH_PREVIEW_HIDE_UNAVAILABLE_STOCK', + 'SEARCH_PREVIEW_SHOW_LOCATIONS', + 'SEARCH_PREVIEW_SHOW_COMPANIES', + 'SEARCH_PREVIEW_SHOW_BUILD_ORDERS', + 'SEARCH_PREVIEW_SHOW_PURCHASE_ORDERS', + 'SEARCH_PREVIEW_EXCLUDE_INACTIVE_PURCHASE_ORDERS', + 'SEARCH_PREVIEW_SHOW_SALES_ORDERS', + 'SEARCH_PREVIEW_EXCLUDE_INACTIVE_SALES_ORDERS', + 'SEARCH_PREVIEW_SHOW_RETURN_ORDERS', + 'SEARCH_PREVIEW_EXCLUDE_INACTIVE_RETURN_ORDERS' + ]} + /> + ) + }, + { + name: 'notifications', + label: t`Notifications`, + icon: <IconBellCog /> + }, + { + name: 'reporting', + label: t`Reporting`, + icon: <IconFileAnalytics />, + content: ( + <UserSettingList + keys={['REPORT_INLINE', 'LABEL_INLINE', 'LABEL_DEFAULT_PRINTER']} + /> + ) + } + ]; + }, []); + + return ( + <> + <Stack spacing="xs"> + <PageDetail + title={t`User Settings`} + detail={<Text>TODO: Filler</Text>} + /> + <PanelGroup pageKey="user-settings" panels={userSettingsPanels} /> + </Stack> + </> + ); +} diff --git a/src/frontend/src/router.tsx b/src/frontend/src/router.tsx index 3ee7fb7843..fdbfcf6070 100644 --- a/src/frontend/src/router.tsx +++ b/src/frontend/src/router.tsx @@ -49,6 +49,18 @@ export const Profile = Loadable( lazy(() => import('./pages/Index/Profile/Profile')) ); +export const UserSettings = Loadable( + lazy(() => import('./pages/Index/UserSettings')) +); + +export const SystemSettings = Loadable( + lazy(() => import('./pages/Index/SystemSettings')) +); + +export const PluginSettings = Loadable( + lazy(() => import('./pages/Index/PluginSettings')) +); + export const NotFound = Loadable(lazy(() => import('./pages/NotFound'))); export const Login = Loadable(lazy(() => import('./pages/Auth/Login'))); export const Logged_In = Loadable(lazy(() => import('./pages/Auth/Logged-In'))); @@ -68,6 +80,11 @@ export const routes = ( <Route path="notifications/" element={<Notifications />} />, <Route path="playground/" element={<Playground />} />, <Route path="scan/" element={<Scan />} />, + <Route path="settings/"> + <Route index element={<SystemSettings />} /> + <Route path="user/" element={<UserSettings />} /> + <Route path="plugin/" element={<PluginSettings />} /> + </Route> <Route path="part/"> <Route index element={<CategoryDetail />} /> <Route path="category/:id" element={<CategoryDetail />} /> diff --git a/src/frontend/src/states/ApiState.tsx b/src/frontend/src/states/ApiState.tsx index 12ad8978a1..05994adfe8 100644 --- a/src/frontend/src/states/ApiState.tsx +++ b/src/frontend/src/states/ApiState.tsx @@ -65,7 +65,13 @@ export enum ApiPaths { purchase_order_list = 'api-purchase-order-list', // Sales Order URLs - sales_order_list = 'api-sales-order-list' + sales_order_list = 'api-sales-order-list', + + // Plugin URLs + plugin_list = 'api-plugin-list', + + project_code_list = 'api-project-code-list', + custom_unit_list = 'api-custom-unit-list' } /** @@ -137,7 +143,12 @@ export function apiEndpoint(path: ApiPaths): string { return 'order/po/'; case ApiPaths.sales_order_list: return 'order/so/'; - + case ApiPaths.plugin_list: + return 'plugins/'; + case ApiPaths.project_code_list: + return 'project-code/'; + case ApiPaths.custom_unit_list: + return 'units/'; default: return ''; } diff --git a/src/frontend/src/states/SettingsState.tsx b/src/frontend/src/states/SettingsState.tsx index 1f0c46b2f3..f7bdb60218 100644 --- a/src/frontend/src/states/SettingsState.tsx +++ b/src/frontend/src/states/SettingsState.tsx @@ -7,9 +7,10 @@ import { api } from '../App'; import { ApiPaths, apiUrl } from './ApiState'; import { Setting } from './states'; -interface SettingsStateProps { +export interface SettingsStateProps { settings: Setting[]; fetchSettings: () => void; + endpoint: ApiPaths; } /** @@ -18,6 +19,7 @@ interface SettingsStateProps { export const useGlobalSettingsState = create<SettingsStateProps>( (set, get) => ({ settings: [], + endpoint: ApiPaths.settings_global_list, fetchSettings: async () => { await api .get(apiUrl(ApiPaths.settings_global_list)) @@ -36,6 +38,7 @@ export const useGlobalSettingsState = create<SettingsStateProps>( */ export const useUserSettingsState = create<SettingsStateProps>((set, get) => ({ settings: [], + endpoint: ApiPaths.settings_user_list, fetchSettings: async () => { await api .get(apiUrl(ApiPaths.settings_user_list)) diff --git a/src/frontend/src/states/UserState.tsx b/src/frontend/src/states/UserState.tsx index a61034899c..ef3f4f5339 100644 --- a/src/frontend/src/states/UserState.tsx +++ b/src/frontend/src/states/UserState.tsx @@ -6,6 +6,7 @@ import { UserProps } from './states'; interface UserStateProps { user: UserProps | undefined; + username: () => string; setUser: (newUser: UserProps) => void; fetchUserState: () => void; } @@ -15,6 +16,15 @@ interface UserStateProps { */ export const useUserState = create<UserStateProps>((set, get) => ({ user: undefined, + username: () => { + const user: UserProps = get().user as UserProps; + + if (user.first_name || user.last_name) { + return `${user.first_name} ${user.last_name}`.trim(); + } else { + return user.username; + } + }, setUser: (newUser: UserProps) => set({ user: newUser }), fetchUserState: async () => { // Fetch user data @@ -22,7 +32,8 @@ export const useUserState = create<UserStateProps>((set, get) => ({ .get(apiUrl(ApiPaths.user_me)) .then((response) => { const user: UserProps = { - name: `${response.data.first_name} ${response.data.last_name}`, + first_name: response.data?.first_name ?? '', + last_name: response.data?.last_name ?? '', email: response.data.email, username: response.data.username }; diff --git a/src/frontend/src/states/states.tsx b/src/frontend/src/states/states.tsx index beda482676..29b97452f9 100644 --- a/src/frontend/src/states/states.tsx +++ b/src/frontend/src/states/states.tsx @@ -9,9 +9,10 @@ export interface HostList { // Type interface fully defining the current user export interface UserProps { - name: string; - email: string; username: string; + first_name: string; + last_name: string; + email: string; is_staff?: boolean; is_superuser?: boolean; roles?: Record<string, string[]>; @@ -61,7 +62,8 @@ export enum SettingTyp { export enum SettingType { Boolean = 'boolean', Integer = 'integer', - String = 'string' + String = 'string', + Choice = 'choice' } export interface PluginProps {