mirror of
https://github.com/inventree/InvenTree.git
synced 2026-06-14 20:40:45 +00:00
Merge branch 'master' into pui-plugins
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"locales": [
|
||||
"ar",
|
||||
"bg",
|
||||
"cs",
|
||||
"da",
|
||||
@@ -8,6 +9,7 @@
|
||||
"en",
|
||||
"es",
|
||||
"es-mx",
|
||||
"et",
|
||||
"fa",
|
||||
"fi",
|
||||
"fr",
|
||||
|
||||
+61
-57
@@ -11,79 +11,83 @@
|
||||
"compile": "lingui compile --typescript"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": ">=6.0.0",
|
||||
"@codemirror/autocomplete": ">=6.18.0",
|
||||
"@codemirror/lang-liquid": "^6.2.1",
|
||||
"@codemirror/language": ">=6.0.0",
|
||||
"@codemirror/lint": ">=6.0.0",
|
||||
"@codemirror/language": ">=6.10.2",
|
||||
"@codemirror/lint": ">=6.8.1",
|
||||
"@codemirror/search": ">=6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/theme-one-dark": ">=6.0.0",
|
||||
"@codemirror/view": ">=6.0.0",
|
||||
"@emotion/react": "^11.11.4",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.2",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.5.2",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.5.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@lingui/core": "^4.10.0",
|
||||
"@lingui/react": "^4.10.0",
|
||||
"@mantine/carousel": "^7.8.0",
|
||||
"@mantine/charts": "^7.10.1",
|
||||
"@mantine/core": "^7.10.0",
|
||||
"@mantine/dates": "^7.8.0",
|
||||
"@mantine/dropzone": "^7.8.0",
|
||||
"@mantine/form": "^7.8.0",
|
||||
"@mantine/hooks": "^7.8.0",
|
||||
"@mantine/modals": "^7.8.0",
|
||||
"@mantine/notifications": "^7.8.0",
|
||||
"@mantine/spotlight": "^7.8.0",
|
||||
"@mantine/vanilla-extract": "^7.8.0",
|
||||
"@mdxeditor/editor": "^3.0.7",
|
||||
"@naisutech/react-tree": "^3.1.0",
|
||||
"@sentry/react": "^7.110.0",
|
||||
"@tabler/icons-react": "^3.2.0",
|
||||
"@tanstack/react-query": "^5.29.2",
|
||||
"@uiw/codemirror-theme-vscode": "^4.21.25",
|
||||
"@uiw/react-codemirror": "^4.21.25",
|
||||
"@codemirror/view": ">=6.30.0",
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.6.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.6.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.2",
|
||||
"@lingui/core": "^4.11.2",
|
||||
"@lingui/react": "^4.11.2",
|
||||
"@mantine/carousel": "^7.12.0",
|
||||
"@mantine/charts": "^7.12.0",
|
||||
"@mantine/core": "^7.12.0",
|
||||
"@mantine/dates": "^7.12.0",
|
||||
"@mantine/dropzone": "^7.12.0",
|
||||
"@mantine/form": "^7.12.0",
|
||||
"@mantine/hooks": "^7.12.0",
|
||||
"@mantine/modals": "^7.12.0",
|
||||
"@mantine/notifications": "^7.12.0",
|
||||
"@mantine/spotlight": "^7.12.0",
|
||||
"@mantine/vanilla-extract": "^7.12.0",
|
||||
"@mdxeditor/editor": "^3.10.1",
|
||||
"@sentry/react": "^8.23.0",
|
||||
"@tabler/icons-react": "^3.11.0",
|
||||
"@tanstack/react-query": "^5.51.21",
|
||||
"@uiw/codemirror-theme-vscode": "^4.23.0",
|
||||
"@uiw/react-codemirror": "^4.23.0",
|
||||
"@uiw/react-split": "^5.9.3",
|
||||
"@vanilla-extract/css": "^1.14.2",
|
||||
"axios": "^1.6.8",
|
||||
"@vanilla-extract/css": "^1.15.3",
|
||||
"axios": "^1.7.3",
|
||||
"clsx": "^2.1.0",
|
||||
"codemirror": ">=6.0.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"embla-carousel-react": "^8.0.2",
|
||||
"dayjs": "^1.11.12",
|
||||
"embla-carousel-react": "^8.1.8",
|
||||
"fuse.js": "^7.0.0",
|
||||
"html5-qrcode": "^2.3.8",
|
||||
"mantine-datatable": "^7.8.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"mantine-datatable": "^7.11.3",
|
||||
"qrcode": "^1.5.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-grid-layout": "^1.4.4",
|
||||
"react-hook-form": "^7.51.3",
|
||||
"react-is": "^18.2.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-hook-form": "^7.52.2",
|
||||
"react-is": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"react-select": "^5.8.0",
|
||||
"react-window": "^1.8.10",
|
||||
"recharts": "^2.12.4",
|
||||
"styled-components": "^6.1.8",
|
||||
"zustand": "^4.5.2"
|
||||
"styled-components": "^6.1.12",
|
||||
"zustand": "^4.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/preset-react": "^7.24.1",
|
||||
"@babel/preset-typescript": "^7.24.1",
|
||||
"@lingui/cli": "^4.10.0",
|
||||
"@lingui/macro": "^4.10.0",
|
||||
"@playwright/test": "^1.43.1",
|
||||
"@types/node": "^20.12.7",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/preset-react": "^7.24.7",
|
||||
"@babel/preset-typescript": "^7.24.7",
|
||||
"@lingui/cli": "^4.11.2",
|
||||
"@lingui/macro": "^4.11.2",
|
||||
"@playwright/test": "^1.45.3",
|
||||
"@types/node": "^22.1.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-grid-layout": "^1.3.5",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vanilla-extract/vite-plugin": "^4.0.7",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@vanilla-extract/vite-plugin": "^4.0.13",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"babel-plugin-macros": "^3.1.0",
|
||||
"nyc": "^15.1.0",
|
||||
"rollup-plugin-license": "^3.3.1",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.8",
|
||||
"nyc": "^17.0.0",
|
||||
"rollup-plugin-license": "^3.5.2",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.3.5",
|
||||
"vite-plugin-babel-macros": "^1.0.6",
|
||||
"vite-plugin-istanbul": "^6.0.0"
|
||||
"vite-plugin-istanbul": "^6.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { defineConfig, devices } from '@playwright/test';
|
||||
export default defineConfig({
|
||||
testDir: './tests',
|
||||
fullyParallel: true,
|
||||
timeout: 60000,
|
||||
timeout: 90000,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: process.env.CI ? 2 : undefined,
|
||||
|
||||
@@ -29,4 +29,10 @@ export function setApiDefaults() {
|
||||
}
|
||||
}
|
||||
|
||||
export const queryClient = new QueryClient();
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ export function Boundary({
|
||||
fallback?: React.ReactElement | FallbackRender | undefined;
|
||||
}): ReactNode {
|
||||
const onError = useCallback(
|
||||
(error: Error, componentStack: string, eventId: string) => {
|
||||
(error: unknown, componentStack: string | undefined, eventId: string) => {
|
||||
console.error(`Error rendering component: ${label}`);
|
||||
console.error(error, componentStack);
|
||||
},
|
||||
|
||||
@@ -31,7 +31,7 @@ export function DashboardItemProxy({
|
||||
queryFn: fetchData,
|
||||
refetchOnWindowFocus: autoupdate
|
||||
});
|
||||
const [dashdata, setDashData] = useState({ title: t`Title`, value: '000' });
|
||||
const [dashData, setDashData] = useState({ title: t`Title`, value: '000' });
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
@@ -44,7 +44,7 @@ export function DashboardItemProxy({
|
||||
<div key={id}>
|
||||
<StatisticItem
|
||||
id={id}
|
||||
data={dashdata}
|
||||
data={dashData}
|
||||
isLoading={isLoading || isFetching}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ export function ActionButton(props: ActionButtonProps) {
|
||||
props.tooltip ?? props.text ?? ''
|
||||
)}`}
|
||||
onClick={props.onClick ?? notYetImplemented}
|
||||
variant={props.variant ?? 'light'}
|
||||
variant={props.variant ?? 'transparent'}
|
||||
>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
{props.icon}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { IconUserStar } from '@tabler/icons-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
import { base_url } from '../../main';
|
||||
import { useLocalState } from '../../states/LocalState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { ModelInformationDict } from '../render/ModelType';
|
||||
|
||||
@@ -11,7 +11,7 @@ export function ButtonMenu({
|
||||
label = ''
|
||||
}: {
|
||||
icon: any;
|
||||
actions: any[];
|
||||
actions: React.ReactNode[];
|
||||
label?: string;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Button, CopyButton as MantineCopyButton } from '@mantine/core';
|
||||
import { IconCopy } from '@tabler/icons-react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
CopyButton as MantineCopyButton,
|
||||
Text,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
|
||||
import { InvenTreeIcon } from '../../functions/icons';
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
@@ -9,20 +16,27 @@ export function CopyButton({
|
||||
value: any;
|
||||
label?: JSX.Element;
|
||||
}) {
|
||||
const ButtonComponent = label ? Button : ActionIcon;
|
||||
|
||||
return (
|
||||
<MantineCopyButton value={value}>
|
||||
{({ copied, copy }) => (
|
||||
<Button
|
||||
color={copied ? 'teal' : 'gray'}
|
||||
onClick={copy}
|
||||
title={t`Copy to clipboard`}
|
||||
variant="subtle"
|
||||
size="compact-md"
|
||||
>
|
||||
<IconCopy size={10} />
|
||||
{label && <div> </div>}
|
||||
{label && label}
|
||||
</Button>
|
||||
<Tooltip label={copied ? t`Copied` : t`Copy`} withArrow>
|
||||
<ButtonComponent
|
||||
color={copied ? 'teal' : 'gray'}
|
||||
onClick={copy}
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
>
|
||||
{copied ? (
|
||||
<InvenTreeIcon icon="check" />
|
||||
) : (
|
||||
<InvenTreeIcon icon="copy" />
|
||||
)}
|
||||
|
||||
{label && <Text ml={10}>{label}</Text>}
|
||||
</ButtonComponent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</MantineCopyButton>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Button, Tooltip } from '@mantine/core';
|
||||
|
||||
import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
|
||||
import { notYetImplemented } from '../../functions/notifications';
|
||||
|
||||
/**
|
||||
* A "primary action" button for display on a page detail, (for example)
|
||||
*/
|
||||
export default function PrimaryActionButton({
|
||||
title,
|
||||
tooltip,
|
||||
icon,
|
||||
color,
|
||||
hidden,
|
||||
onClick
|
||||
}: {
|
||||
title: string;
|
||||
tooltip?: string;
|
||||
icon?: InvenTreeIconType;
|
||||
color?: string;
|
||||
hidden?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
if (hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip label={tooltip ?? title} position="bottom" hidden={!tooltip}>
|
||||
<Button
|
||||
leftSection={icon && <InvenTreeIcon icon={icon} />}
|
||||
color={color}
|
||||
radius="sm"
|
||||
p="xs"
|
||||
onClick={onClick ?? notYetImplemented}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconPrinter, IconReport, IconTags } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
@@ -32,31 +33,28 @@ export function PrintingActions({
|
||||
|
||||
const [pluginKey, setPluginKey] = useState<string>('');
|
||||
|
||||
const loadFields = useCallback(() => {
|
||||
if (!enableLabels) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.options(apiUrl(ApiEndpoints.label_print), {
|
||||
params: {
|
||||
plugin: pluginKey || undefined
|
||||
}
|
||||
})
|
||||
.then((response: any) => {
|
||||
setExtraFields(extractAvailableFields(response, 'POST') || {});
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [enableLabels, pluginKey]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFields();
|
||||
}, [loadFields, pluginKey]);
|
||||
|
||||
const [extraFields, setExtraFields] = useState<ApiFormFieldSet>({});
|
||||
// Fetch available printing fields via OPTIONS request
|
||||
const printingFields = useQuery({
|
||||
enabled: enableLabels,
|
||||
queryKey: ['printingFields', modelType, pluginKey],
|
||||
gcTime: 500,
|
||||
queryFn: () =>
|
||||
api
|
||||
.options(apiUrl(ApiEndpoints.label_print), {
|
||||
params: {
|
||||
plugin: pluginKey || undefined
|
||||
}
|
||||
})
|
||||
.then((response: any) => {
|
||||
return extractAvailableFields(response, 'POST') || {};
|
||||
})
|
||||
.catch(() => {
|
||||
return {};
|
||||
})
|
||||
});
|
||||
|
||||
const labelFields: ApiFormFieldSet = useMemo(() => {
|
||||
let fields: ApiFormFieldSet = extraFields;
|
||||
let fields: ApiFormFieldSet = printingFields.data || {};
|
||||
|
||||
// Override field values
|
||||
fields['template'] = {
|
||||
@@ -88,7 +86,7 @@ export function PrintingActions({
|
||||
};
|
||||
|
||||
return fields;
|
||||
}, [extraFields, items, loadFields]);
|
||||
}, [printingFields.data, items]);
|
||||
|
||||
const labelModal = useCreateApiFormModal({
|
||||
url: apiUrl(ApiEndpoints.label_print),
|
||||
@@ -98,6 +96,7 @@ export function PrintingActions({
|
||||
onClose: () => {
|
||||
setPluginKey('');
|
||||
},
|
||||
submitText: t`Print`,
|
||||
successMessage: t`Label printing completed successfully`,
|
||||
onFormSuccess: (response: any) => {
|
||||
setPluginKey('');
|
||||
@@ -136,6 +135,7 @@ export function PrintingActions({
|
||||
value: items
|
||||
}
|
||||
},
|
||||
submitText: t`Generate`,
|
||||
successMessage: t`Report printing completed successfully`,
|
||||
onFormSuccess: (response: any) => {
|
||||
if (!response.complete) {
|
||||
|
||||
@@ -22,6 +22,7 @@ export function PassFailButton({
|
||||
variant="filled"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
style={{ maxWidth: '50px' }}
|
||||
>
|
||||
{v ? pass : fail}
|
||||
</Badge>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
Badge,
|
||||
CopyButton,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { getValueAtPath } from 'mantine-datatable';
|
||||
@@ -24,23 +21,13 @@ import { navigateToLink } from '../../functions/navigation';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { CopyButton } from '../buttons/CopyButton';
|
||||
import { YesNoButton } from '../buttons/YesNoButton';
|
||||
import { ProgressBar } from '../items/ProgressBar';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
import { getModelInfo } from '../render/ModelType';
|
||||
import { StatusRenderer } from '../render/StatusRenderer';
|
||||
|
||||
export type PartIconsType = {
|
||||
assembly: boolean;
|
||||
template: boolean;
|
||||
component: boolean;
|
||||
trackable: boolean;
|
||||
purchaseable: boolean;
|
||||
saleable: boolean;
|
||||
virtual: boolean;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
export type DetailsField =
|
||||
| {
|
||||
hidden?: boolean;
|
||||
@@ -59,7 +46,7 @@ export type DetailsField =
|
||||
);
|
||||
|
||||
type BadgeType = 'owner' | 'user' | 'group';
|
||||
type ValueFormatterReturn = string | number | null;
|
||||
type ValueFormatterReturn = string | number | null | React.ReactNode;
|
||||
|
||||
type StringDetailField = {
|
||||
type: 'string' | 'text';
|
||||
@@ -135,11 +122,11 @@ function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) {
|
||||
case 200:
|
||||
return response.data;
|
||||
default:
|
||||
return null;
|
||||
return {};
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
return {};
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -148,7 +135,9 @@ function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) {
|
||||
|
||||
// Rendering a user's rame for the badge
|
||||
function _render_name() {
|
||||
if (type === 'user' && settings.isSet('DISPLAY_FULL_NAMES')) {
|
||||
if (!data) {
|
||||
return '';
|
||||
} else if (type === 'user' && settings.isSet('DISPLAY_FULL_NAMES')) {
|
||||
if (data.first_name || data.last_name) {
|
||||
return `${data.first_name} ${data.last_name}`;
|
||||
} else {
|
||||
@@ -169,7 +158,7 @@ function NameBadge({ pk, type }: { pk: string | number; type: BadgeType }) {
|
||||
variant="filled"
|
||||
style={{ display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
{data.name ?? _render_name()}
|
||||
{data?.name ?? _render_name()}
|
||||
</Badge>
|
||||
<InvenTreeIcon icon={type === 'user' ? type : data.label} />
|
||||
</div>
|
||||
@@ -334,26 +323,7 @@ function StatusValue(props: Readonly<FieldProps>) {
|
||||
}
|
||||
|
||||
function CopyField({ value }: { value: string }) {
|
||||
return (
|
||||
<CopyButton value={value}>
|
||||
{({ copied, copy }) => (
|
||||
<Tooltip label={copied ? t`Copied` : t`Copy`} withArrow>
|
||||
<ActionIcon
|
||||
color={copied ? 'teal' : 'gray'}
|
||||
onClick={copy}
|
||||
variant="transparent"
|
||||
size="sm"
|
||||
>
|
||||
{copied ? (
|
||||
<InvenTreeIcon icon="check" />
|
||||
) : (
|
||||
<InvenTreeIcon icon="copy" />
|
||||
)}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</CopyButton>
|
||||
);
|
||||
return <CopyButton value={value} />;
|
||||
}
|
||||
|
||||
export function DetailsTableField({
|
||||
@@ -398,10 +368,10 @@ export function DetailsTableField({
|
||||
>
|
||||
<InvenTreeIcon icon={field.icon ?? (field.name as InvenTreeIconType)} />
|
||||
</Table.Td>
|
||||
<Table.Td style={{ maxWidth: '65%' }}>
|
||||
<Table.Td style={{ maxWidth: '65%', lineBreak: 'auto' }}>
|
||||
<Text>{field.label}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td style={{}}>
|
||||
<Table.Td style={{ lineBreak: 'anywhere' }}>
|
||||
<FieldType field_data={field} field_value={fieldValue} />
|
||||
</Table.Td>
|
||||
<Table.Td style={{ width: '50' }}>
|
||||
|
||||
@@ -85,7 +85,7 @@ function UploadModal({
|
||||
apiPath: string;
|
||||
setImage: (image: string) => void;
|
||||
}) {
|
||||
const [file1, setFile] = useState<FileWithPath | null>(null);
|
||||
const [currentFile, setCurrentFile] = useState<FileWithPath | null>(null);
|
||||
let uploading = false;
|
||||
|
||||
// Components to show in the Dropzone when no file is selected
|
||||
@@ -168,7 +168,7 @@ function UploadModal({
|
||||
return (
|
||||
<Paper style={{ height: '220px' }}>
|
||||
<Dropzone
|
||||
onDrop={(files) => setFile(files[0])}
|
||||
onDrop={(files) => setCurrentFile(files[0])}
|
||||
maxFiles={1}
|
||||
accept={IMAGE_MIME_TYPE}
|
||||
loading={uploading}
|
||||
@@ -198,7 +198,9 @@ function UploadModal({
|
||||
}}
|
||||
/>
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>{file1 ? fileInfo(file1) : noFileIdle}</Dropzone.Idle>
|
||||
<Dropzone.Idle>
|
||||
{currentFile ? fileInfo(currentFile) : noFileIdle}
|
||||
</Dropzone.Idle>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
<Paper
|
||||
@@ -218,12 +220,15 @@ function UploadModal({
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!file1}
|
||||
onClick={() => setFile(null)}
|
||||
disabled={!currentFile}
|
||||
onClick={() => setCurrentFile(null)}
|
||||
>
|
||||
<Trans>Clear</Trans>
|
||||
</Button>
|
||||
<Button disabled={!file1} onClick={() => uploadImage(file1)}>
|
||||
<Button
|
||||
disabled={!currentFile}
|
||||
onClick={() => uploadImage(currentFile)}
|
||||
>
|
||||
<Trans>Submit</Trans>
|
||||
</Button>
|
||||
</Paper>
|
||||
@@ -354,31 +359,27 @@ export function DetailsImage(props: Readonly<DetailImageProps>) {
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AspectRatio ref={ref} maw={IMAGE_DIMENSION} ratio={1} pos="relative">
|
||||
<>
|
||||
<ApiImage
|
||||
src={img}
|
||||
mah={IMAGE_DIMENSION}
|
||||
maw={IMAGE_DIMENSION}
|
||||
onClick={expandImage}
|
||||
/>
|
||||
{permissions.hasChangeRole(props.appRole) &&
|
||||
hasOverlay &&
|
||||
hovered && (
|
||||
<Overlay color="black" opacity={0.8} onClick={expandImage}>
|
||||
<ImageActionButtons
|
||||
visible={hovered}
|
||||
actions={props.imageActions}
|
||||
apiPath={props.apiPath}
|
||||
hasImage={props.src ? true : false}
|
||||
pk={props.pk}
|
||||
setImage={setAndRefresh}
|
||||
/>
|
||||
</Overlay>
|
||||
)}
|
||||
</>
|
||||
</AspectRatio>
|
||||
</>
|
||||
<AspectRatio ref={ref} maw={IMAGE_DIMENSION} ratio={1} pos="relative">
|
||||
<>
|
||||
<ApiImage
|
||||
src={img}
|
||||
mah={IMAGE_DIMENSION}
|
||||
maw={IMAGE_DIMENSION}
|
||||
onClick={expandImage}
|
||||
/>
|
||||
{permissions.hasChangeRole(props.appRole) && hasOverlay && hovered && (
|
||||
<Overlay color="black" opacity={0.8} onClick={expandImage}>
|
||||
<ImageActionButtons
|
||||
visible={hovered}
|
||||
actions={props.imageActions}
|
||||
apiPath={props.apiPath}
|
||||
hasImage={props.src ? true : false}
|
||||
pk={props.pk}
|
||||
setImage={setAndRefresh}
|
||||
/>
|
||||
</Overlay>
|
||||
)}
|
||||
</>
|
||||
</AspectRatio>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Badge, Tooltip } from '@mantine/core';
|
||||
|
||||
import { InvenTreeIcon, InvenTreeIconType } from '../../functions/icons';
|
||||
|
||||
/**
|
||||
* Fetches and wraps an InvenTreeIcon in a flex div
|
||||
* @param icon name of icon
|
||||
*
|
||||
*/
|
||||
function PartIcon(icon: InvenTreeIconType) {
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||
<InvenTreeIcon icon={icon} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a table cell with Part icons.
|
||||
* Only used for Part Model Details
|
||||
*/
|
||||
export function PartIcons({ part }: { part: any }) {
|
||||
return (
|
||||
<td colSpan={2}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
{!part.active && (
|
||||
<Tooltip label={t`Part is not active`}>
|
||||
<Badge color="red" variant="filled">
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
|
||||
>
|
||||
<InvenTreeIcon icon="inactive" iconProps={{ size: 19 }} />{' '}
|
||||
<Trans>Inactive</Trans>
|
||||
</div>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{part.template && (
|
||||
<Tooltip
|
||||
label={t`Part is a template part (variants can be made from this part)`}
|
||||
children={PartIcon('template')}
|
||||
/>
|
||||
)}
|
||||
{part.assembly && (
|
||||
<Tooltip
|
||||
label={t`Part can be assembled from other parts`}
|
||||
children={PartIcon('assembly')}
|
||||
/>
|
||||
)}
|
||||
{part.component && (
|
||||
<Tooltip
|
||||
label={t`Part can be used in assemblies`}
|
||||
children={PartIcon('component')}
|
||||
/>
|
||||
)}
|
||||
{part.trackable && (
|
||||
<Tooltip
|
||||
label={t`Part stock is tracked by serial number`}
|
||||
children={PartIcon('trackable')}
|
||||
/>
|
||||
)}
|
||||
{part.purchaseable && (
|
||||
<Tooltip
|
||||
label={t`Part can be purchased from external suppliers`}
|
||||
children={PartIcon('purchaseable')}
|
||||
/>
|
||||
)}
|
||||
{part.saleable && (
|
||||
<Tooltip
|
||||
label={t`Part can be sold to customers`}
|
||||
children={PartIcon('saleable')}
|
||||
/>
|
||||
)}
|
||||
{part.virtual && (
|
||||
<Tooltip label={t`Part is virtual (not a physical part)`}>
|
||||
<Badge color="yellow" variant="filled">
|
||||
<div
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '5px' }}
|
||||
>
|
||||
<InvenTreeIcon icon="virtual" iconProps={{ size: 18 }} />{' '}
|
||||
<Trans>Virtual</Trans>
|
||||
</div>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -142,7 +142,12 @@ export default function NotesEditor({
|
||||
|
||||
// Callback to save notes to the server
|
||||
const saveNotes = useCallback(() => {
|
||||
const markdown = ref.current?.getMarkdown() ?? '';
|
||||
const markdown = ref.current?.getMarkdown();
|
||||
|
||||
if (!noteUrl || markdown === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
api
|
||||
.patch(noteUrl, { notes: markdown })
|
||||
.then(() => {
|
||||
@@ -163,7 +168,7 @@ export default function NotesEditor({
|
||||
id: 'notes'
|
||||
});
|
||||
});
|
||||
}, [noteUrl, ref.current]);
|
||||
}, [api, noteUrl, ref.current]);
|
||||
|
||||
const plugins: any[] = useMemo(() => {
|
||||
let plg = [
|
||||
|
||||
@@ -25,12 +25,18 @@ const tags: Tag[] = [
|
||||
description: 'Generate a QR code image',
|
||||
args: ['data'],
|
||||
kwargs: {
|
||||
fill_color: 'Fill color (default = black)',
|
||||
back_color: 'Background color (default = white)',
|
||||
version: 'Version (default = 1)',
|
||||
box_size: 'Box size (default = 20)',
|
||||
border: 'Border width (default = 1)',
|
||||
format: 'Format (default = PNG)'
|
||||
version: 'QR code version, (None to auto detect) (default = None)',
|
||||
error_correction:
|
||||
"Error correction level (L: 7%, M: 15%, Q: 25%, H: 30%) (default = 'Q')",
|
||||
box_size:
|
||||
'pixel dimensions for one black square pixel in the QR code (default = 20)',
|
||||
border:
|
||||
'count white QR square pixels around the qr code, needed as padding (default = 1)',
|
||||
optimize:
|
||||
'data will be split into multiple chunks of at least this length using different modes (text, alphanumeric, binary) to optimize the QR code size. Set to `0` to disable. (default = 1)',
|
||||
format: "Image format (default = 'PNG')",
|
||||
fill_color: 'Fill color (default = "black")',
|
||||
back_color: 'Background color (default = "white")'
|
||||
},
|
||||
returns: 'base64 encoded qr code image data'
|
||||
},
|
||||
|
||||
@@ -81,7 +81,9 @@ export const PdfPreviewComponent: PreviewAreaComponent = forwardRef(
|
||||
<Trans>Preview not available, click "Reload Preview".</Trans>
|
||||
</div>
|
||||
)}
|
||||
{pdfUrl && <iframe src={pdfUrl} width="100%" height="100%" />}
|
||||
{pdfUrl && (
|
||||
<iframe src={pdfUrl} width="100%" height="100%" title="PDF Preview" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -210,7 +210,11 @@ export function TemplateEditor(props: Readonly<TemplateEditorProps>) {
|
||||
);
|
||||
|
||||
const templateFilters: Record<string, string> = useMemo(() => {
|
||||
// TODO: Extract custom filters from template
|
||||
// TODO: Extract custom filters from template (make this more generic)
|
||||
if (template.model_type === ModelType.stockitem) {
|
||||
return { part_detail: 'true' } as Record<string, string>;
|
||||
}
|
||||
|
||||
return {};
|
||||
}, [template]);
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import GenericErrorPage from './GenericErrorPage';
|
||||
import NotAuthenticated from './NotAuthenticated';
|
||||
import NotFound from './NotFound';
|
||||
import PermissionDenied from './PermissionDenied';
|
||||
|
||||
export default function ClientError({ status }: { status?: number }) {
|
||||
switch (status) {
|
||||
case 401:
|
||||
return <NotAuthenticated />;
|
||||
case 403:
|
||||
return <PermissionDenied />;
|
||||
case 404:
|
||||
return <NotFound />;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Generic client error
|
||||
return (
|
||||
<GenericErrorPage
|
||||
title={t`Client Error`}
|
||||
message={t`Client error occurred`}
|
||||
status={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack, IconExclamationCircle } from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { LanguageContext } from '../../contexts/LanguageContext';
|
||||
|
||||
export default function ErrorPage({
|
||||
title,
|
||||
message,
|
||||
status
|
||||
}: {
|
||||
title: string;
|
||||
message: string;
|
||||
status?: number;
|
||||
redirectMessage?: string;
|
||||
redirectTarget?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<LanguageContext>
|
||||
<Center>
|
||||
<Container w="md" miw={400}>
|
||||
<Card withBorder shadow="xs" padding="xl" radius="sm">
|
||||
<Card.Section p="lg">
|
||||
<Group gap="xs">
|
||||
<ActionIcon color="red" variant="transparent" size="xl">
|
||||
<IconExclamationCircle />
|
||||
</ActionIcon>
|
||||
<Text size="xl">{title}</Text>
|
||||
</Group>
|
||||
</Card.Section>
|
||||
<Divider />
|
||||
<Card.Section p="lg">
|
||||
<Stack gap="md">
|
||||
<Text size="lg">{message}</Text>
|
||||
{status && (
|
||||
<Text>
|
||||
<Trans>Status Code</Trans>: {status}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card.Section>
|
||||
<Divider />
|
||||
<Card.Section p="lg">
|
||||
<Center>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="green"
|
||||
onClick={() => navigate('/')}
|
||||
>
|
||||
<Trans>Return to the index page</Trans>
|
||||
<IconArrowBack />
|
||||
</Button>
|
||||
</Center>
|
||||
</Card.Section>
|
||||
</Card>
|
||||
</Container>
|
||||
</Center>
|
||||
</LanguageContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import GenericErrorPage from './GenericErrorPage';
|
||||
|
||||
export default function NotAuthenticated() {
|
||||
return (
|
||||
<GenericErrorPage
|
||||
title={t`Not Authenticated`}
|
||||
message={t`You are not logged in.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import GenericErrorPage from './GenericErrorPage';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<GenericErrorPage
|
||||
title={t`Page Not Found`}
|
||||
message={t`This page does not exist`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import GenericErrorPage from './GenericErrorPage';
|
||||
|
||||
export default function PermissionDenied() {
|
||||
return (
|
||||
<GenericErrorPage
|
||||
title={t`Permission Denied`}
|
||||
message={t`You do not have permission to view this page.`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { t } from '@lingui/macro';
|
||||
|
||||
import GenericErrorPage from './GenericErrorPage';
|
||||
|
||||
export default function ServerError({ status }: { status?: number }) {
|
||||
return (
|
||||
<GenericErrorPage
|
||||
title={t`Server Error`}
|
||||
message={t`A server error occurred`}
|
||||
status={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -67,6 +67,7 @@ export interface ApiFormAction {
|
||||
* @param successMessage : Optional message to display on successful form submission
|
||||
* @param onFormSuccess : A callback function to call when the form is submitted successfully.
|
||||
* @param onFormError : A callback function to call when the form is submitted with errors.
|
||||
* @param processFormData : A callback function to process the form data before submission
|
||||
* @param modelType : Define a model type for this form
|
||||
* @param follow : Boolean, follow the result of the form (if possible)
|
||||
* @param table : Table to update on success (if provided)
|
||||
@@ -91,6 +92,7 @@ export interface ApiFormProps {
|
||||
successMessage?: string;
|
||||
onFormSuccess?: (data: any) => void;
|
||||
onFormError?: () => void;
|
||||
processFormData?: (data: any) => any;
|
||||
table?: TableState;
|
||||
modelType?: ModelType;
|
||||
follow?: boolean;
|
||||
@@ -123,7 +125,6 @@ export function OptionsApiForm({
|
||||
const optionsQuery = useQuery({
|
||||
enabled: true,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
queryKey: [
|
||||
'form-options-data',
|
||||
id,
|
||||
@@ -138,6 +139,7 @@ export function OptionsApiForm({
|
||||
if (!props.ignorePermissionCheck) {
|
||||
fields = extractAvailableFields(response, props.method);
|
||||
}
|
||||
|
||||
return fields;
|
||||
},
|
||||
throwOnError: (error: any) => {
|
||||
@@ -182,7 +184,7 @@ export function OptionsApiForm({
|
||||
<ApiForm
|
||||
id={id}
|
||||
props={formProps}
|
||||
optionsLoading={optionsQuery.isFetching}
|
||||
optionsLoading={optionsQuery.isFetching || !optionsQuery.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -202,9 +204,9 @@ export function ApiForm({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const fields: ApiFormFieldSet = useMemo(() => {
|
||||
return props.fields ?? {};
|
||||
}, [props.fields]);
|
||||
const [fields, setFields] = useState<ApiFormFieldSet>(
|
||||
() => props.fields ?? {}
|
||||
);
|
||||
|
||||
const defaultValues: FieldValues = useMemo(() => {
|
||||
let defaultValuesMap = mapFields(fields ?? {}, (_path, field) => {
|
||||
@@ -247,6 +249,31 @@ export function ApiForm({
|
||||
[props.url, props.pk, props.pathParams]
|
||||
);
|
||||
|
||||
// Define function to process API response
|
||||
const processFields = (fields: ApiFormFieldSet, data: NestedDict) => {
|
||||
const res: NestedDict = {};
|
||||
|
||||
for (const [k, field] of Object.entries(fields)) {
|
||||
const dataValue = data[k];
|
||||
|
||||
if (
|
||||
field.field_type === 'nested object' &&
|
||||
field.children &&
|
||||
typeof dataValue === 'object'
|
||||
) {
|
||||
res[k] = processFields(field.children, dataValue);
|
||||
} else {
|
||||
res[k] = dataValue;
|
||||
|
||||
if (field.onValueChange) {
|
||||
field.onValueChange(dataValue, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
// Query manager for retrieving initial data from the server
|
||||
const initialDataQuery = useQuery({
|
||||
enabled: false,
|
||||
@@ -259,66 +286,51 @@ export function ApiForm({
|
||||
props.pathParams
|
||||
],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
// Await API call
|
||||
let response = await api.get(url);
|
||||
return await api
|
||||
.get(url)
|
||||
.then((response: any) => {
|
||||
// Process API response
|
||||
const fetchedData: any = processFields(fields, response.data);
|
||||
|
||||
// Define function to process API response
|
||||
const processFields = (fields: ApiFormFieldSet, data: NestedDict) => {
|
||||
const res: NestedDict = {};
|
||||
|
||||
// TODO: replace with .map()
|
||||
for (const [k, field] of Object.entries(fields)) {
|
||||
const dataValue = data[k];
|
||||
|
||||
if (
|
||||
field.field_type === 'nested object' &&
|
||||
field.children &&
|
||||
typeof dataValue === 'object'
|
||||
) {
|
||||
res[k] = processFields(field.children, dataValue);
|
||||
} else {
|
||||
res[k] = dataValue;
|
||||
|
||||
if (field.onValueChange) {
|
||||
field.onValueChange(dataValue, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
// Process API response
|
||||
const initialData: any = processFields(fields, response.data);
|
||||
|
||||
// Update form values, but only for the fields specified for this form
|
||||
form.reset(initialData);
|
||||
|
||||
// Update the field references, too
|
||||
Object.keys(fields).forEach((fieldName) => {
|
||||
if (fieldName in initialData) {
|
||||
let field = fields[fieldName] ?? {};
|
||||
fields[fieldName] = {
|
||||
...field,
|
||||
value: initialData[fieldName]
|
||||
};
|
||||
}
|
||||
// Update form values, but only for the fields specified for this form
|
||||
form.reset(fetchedData);
|
||||
return fetchedData;
|
||||
})
|
||||
.catch(() => {
|
||||
return {};
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Error fetching initial data:', error);
|
||||
// Re-throw error to allow react-query to handle error
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let _fields: any = props.fields || {};
|
||||
let _initialData: any = props.initialData || {};
|
||||
let _fetchedData: any = initialDataQuery.data || {};
|
||||
|
||||
for (const k of Object.keys(_fields)) {
|
||||
// Ensure default values override initial field spec
|
||||
if (k in defaultValues) {
|
||||
_fields[k].value = defaultValues[k];
|
||||
}
|
||||
|
||||
// Ensure initial data overrides default values
|
||||
if (_initialData && k in _initialData) {
|
||||
_fields[k].value = _initialData[k];
|
||||
}
|
||||
|
||||
// Ensure fetched data overrides also
|
||||
if (_fetchedData && k in _fetchedData) {
|
||||
_fields[k].value = _fetchedData[k];
|
||||
}
|
||||
}
|
||||
|
||||
setFields(_fields);
|
||||
}, [props.fields, props.initialData, defaultValues, initialDataQuery.data]);
|
||||
|
||||
// Fetch initial data on form load
|
||||
useEffect(() => {
|
||||
// Fetch initial data if the fetchInitialData property is set
|
||||
if (props.fetchInitialData) {
|
||||
if (!optionsLoading && props.fetchInitialData) {
|
||||
queryClient.removeQueries({
|
||||
queryKey: [
|
||||
'form-initial-data',
|
||||
@@ -331,22 +343,16 @@ export function ApiForm({
|
||||
});
|
||||
initialDataQuery.refetch();
|
||||
}
|
||||
}, [props.fetchInitialData]);
|
||||
}, [props.fetchInitialData, optionsLoading]);
|
||||
|
||||
const isLoading = useMemo(
|
||||
const isLoading: boolean = useMemo(
|
||||
() =>
|
||||
isFormLoading ||
|
||||
initialDataQuery.isFetching ||
|
||||
optionsLoading ||
|
||||
isSubmitting ||
|
||||
!fields,
|
||||
[
|
||||
isFormLoading,
|
||||
initialDataQuery.isFetching,
|
||||
isSubmitting,
|
||||
fields,
|
||||
optionsLoading
|
||||
]
|
||||
[isFormLoading, initialDataQuery, isSubmitting, fields, optionsLoading]
|
||||
);
|
||||
|
||||
const [initialFocus, setInitialFocus] = useState<string>('');
|
||||
@@ -362,17 +368,22 @@ export function ApiForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not auto-focus on a 'choice' field
|
||||
if (field.field_type == 'choice') {
|
||||
return;
|
||||
}
|
||||
|
||||
focusField = fieldName;
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading || initialFocus == focusField) {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setFocus(focusField);
|
||||
setInitialFocus(focusField);
|
||||
}, [props.focus, fields, form.setFocus, isLoading, initialFocus]);
|
||||
}, [props.focus, form.setFocus, isLoading, initialFocus]);
|
||||
|
||||
const submitForm: SubmitHandler<FieldValues> = async (data) => {
|
||||
setNonFieldErrors([]);
|
||||
@@ -380,16 +391,42 @@ export function ApiForm({
|
||||
let method = props.method?.toLowerCase() ?? 'get';
|
||||
|
||||
let hasFiles = false;
|
||||
mapFields(fields, (_path, field) => {
|
||||
if (field.field_type === 'file upload') {
|
||||
|
||||
// Optionally pre-process the data before submitting it
|
||||
if (props.processFormData) {
|
||||
data = props.processFormData(data);
|
||||
}
|
||||
|
||||
let dataForm = new FormData();
|
||||
|
||||
Object.keys(data).forEach((key: string) => {
|
||||
let value: any = data[key];
|
||||
let field_type = fields[key]?.field_type;
|
||||
|
||||
if (field_type == 'file upload' && !!value) {
|
||||
hasFiles = true;
|
||||
}
|
||||
|
||||
// Stringify any JSON objects
|
||||
if (typeof value === 'object') {
|
||||
switch (field_type) {
|
||||
case 'file upload':
|
||||
break;
|
||||
default:
|
||||
value = JSON.stringify(value);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (value != undefined) {
|
||||
dataForm.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return api({
|
||||
method: method,
|
||||
url: url,
|
||||
data: data,
|
||||
data: hasFiles ? dataForm : data,
|
||||
timeout: props.timeout,
|
||||
headers: {
|
||||
'Content-Type': hasFiles ? 'multipart/form-data' : 'application/json'
|
||||
@@ -453,7 +490,11 @@ export function ApiForm({
|
||||
for (const [k, v] of Object.entries(errors)) {
|
||||
const path = _path ? `${_path}.${k}` : k;
|
||||
|
||||
if (k === 'non_field_errors' || k === '__all__') {
|
||||
// Determine if field "k" is valid (exists and is visible)
|
||||
let field = fields[k];
|
||||
let valid = field && !field.hidden;
|
||||
|
||||
if (!valid || k === 'non_field_errors' || k === '__all__') {
|
||||
if (Array.isArray(v)) {
|
||||
_nonFieldErrors.push(...v);
|
||||
}
|
||||
@@ -490,6 +531,14 @@ export function ApiForm({
|
||||
props.onFormError?.();
|
||||
}, [props.onFormError]);
|
||||
|
||||
if (optionsLoading || initialDataQuery.isFetching) {
|
||||
return (
|
||||
<Paper mah={'65vh'}>
|
||||
<LoadingOverlay visible zIndex={1010} />
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Boundary label={`ApiForm-${id}`}>
|
||||
@@ -503,13 +552,15 @@ export function ApiForm({
|
||||
{/* Form Fields */}
|
||||
<Stack gap="sm">
|
||||
{(!isValid || nonFieldErrors.length > 0) && (
|
||||
<Alert radius="sm" color="red" title={t`Error`}>
|
||||
{nonFieldErrors.length > 0 && (
|
||||
<Alert radius="sm" color="red" title={t`Form Error`}>
|
||||
{nonFieldErrors.length > 0 ? (
|
||||
<Stack gap="xs">
|
||||
{nonFieldErrors.map((message) => (
|
||||
<Text key={message}>{message}</Text>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text>{t`Errors exist for one or more form fields`}</Text>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
@@ -529,15 +580,18 @@ export function ApiForm({
|
||||
<Boundary label={`ApiForm-${id}-FormContent`}>
|
||||
<FormProvider {...form}>
|
||||
<Stack gap="xs">
|
||||
{!optionsLoading &&
|
||||
Object.entries(fields).map(([fieldName, field]) => (
|
||||
{Object.entries(fields).map(([fieldName, field]) => {
|
||||
return (
|
||||
<ApiFormField
|
||||
key={fieldName}
|
||||
fieldName={fieldName}
|
||||
definition={field}
|
||||
control={form.control}
|
||||
url={url}
|
||||
setFields={setFields}
|
||||
/>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</FormProvider>
|
||||
</Boundary>
|
||||
|
||||
@@ -5,10 +5,12 @@ import { ApiFormField, ApiFormFieldType } from './fields/ApiFormField';
|
||||
|
||||
export function StandaloneField({
|
||||
fieldDefinition,
|
||||
defaultValue
|
||||
defaultValue,
|
||||
hideLabels
|
||||
}: {
|
||||
fieldDefinition: ApiFormFieldType;
|
||||
defaultValue?: any;
|
||||
hideLabels?: boolean;
|
||||
}) {
|
||||
const defaultValues = useMemo(() => {
|
||||
if (defaultValue)
|
||||
@@ -29,6 +31,7 @@ export function StandaloneField({
|
||||
fieldName="field"
|
||||
definition={fieldDefinition}
|
||||
control={form.control}
|
||||
hideLabels={hideLabels}
|
||||
/>
|
||||
</FormProvider>
|
||||
);
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Alert,
|
||||
FileInput,
|
||||
NumberInput,
|
||||
Stack,
|
||||
Switch,
|
||||
TextInput
|
||||
} from '@mantine/core';
|
||||
import { Alert, FileInput, NumberInput, Stack, Switch } from '@mantine/core';
|
||||
import { UseFormReturnType } from '@mantine/form';
|
||||
import { useId } from '@mantine/hooks';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
import { ReactNode, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Control, FieldValues, useController } from 'react-hook-form';
|
||||
|
||||
@@ -17,9 +9,12 @@ import { ModelType } from '../../../enums/ModelType';
|
||||
import { isTrue } from '../../../functions/conversion';
|
||||
import { ChoiceField } from './ChoiceField';
|
||||
import DateField from './DateField';
|
||||
import { DependentField } from './DependentField';
|
||||
import IconField from './IconField';
|
||||
import { NestedObjectField } from './NestedObjectField';
|
||||
import { RelatedModelField } from './RelatedModelField';
|
||||
import { TableField } from './TableField';
|
||||
import TextField from './TextField';
|
||||
|
||||
export type ApiFormData = UseFormReturnType<Record<string, unknown>>;
|
||||
|
||||
@@ -64,6 +59,7 @@ export type ApiFormFieldType = {
|
||||
| 'email'
|
||||
| 'url'
|
||||
| 'string'
|
||||
| 'icon'
|
||||
| 'boolean'
|
||||
| 'date'
|
||||
| 'datetime'
|
||||
@@ -74,12 +70,14 @@ export type ApiFormFieldType = {
|
||||
| 'choice'
|
||||
| 'file upload'
|
||||
| 'nested object'
|
||||
| 'dependent field'
|
||||
| 'table';
|
||||
api_url?: string;
|
||||
pk_field?: string;
|
||||
model?: ModelType;
|
||||
modelRenderer?: (instance: any) => ReactNode;
|
||||
filters?: any;
|
||||
child?: ApiFormFieldType;
|
||||
children?: { [key: string]: ApiFormFieldType };
|
||||
required?: boolean;
|
||||
choices?: any[];
|
||||
@@ -94,6 +92,7 @@ export type ApiFormFieldType = {
|
||||
onValueChange?: (value: any, record?: any) => void;
|
||||
adjustFilters?: (value: ApiFormAdjustFilterType) => any;
|
||||
headers?: string[];
|
||||
depends_on?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -102,11 +101,17 @@ export type ApiFormFieldType = {
|
||||
export function ApiFormField({
|
||||
fieldName,
|
||||
definition,
|
||||
control
|
||||
control,
|
||||
hideLabels,
|
||||
url,
|
||||
setFields
|
||||
}: {
|
||||
fieldName: string;
|
||||
definition: ApiFormFieldType;
|
||||
control: Control<FieldValues, any>;
|
||||
hideLabels?: boolean;
|
||||
url?: string;
|
||||
setFields?: React.Dispatch<React.SetStateAction<ApiFormFieldSet>>;
|
||||
}) {
|
||||
const fieldId = useId();
|
||||
const controller = useController({
|
||||
@@ -120,7 +125,11 @@ export function ApiFormField({
|
||||
const { value, ref } = field;
|
||||
|
||||
useEffect(() => {
|
||||
if (definition.field_type === 'nested object') return;
|
||||
if (
|
||||
definition.field_type === 'nested object' ||
|
||||
definition.field_type === 'dependent field'
|
||||
)
|
||||
return;
|
||||
|
||||
// hook up the value state to the input field
|
||||
if (definition.value !== undefined) {
|
||||
@@ -128,18 +137,26 @@ export function ApiFormField({
|
||||
}
|
||||
}, [definition.value]);
|
||||
|
||||
const fieldDefinition: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
...definition,
|
||||
label: hideLabels ? undefined : definition.label,
|
||||
description: hideLabels ? undefined : definition.description
|
||||
};
|
||||
}, [hideLabels, definition]);
|
||||
|
||||
// pull out onValueChange as this can cause strange errors when passing the
|
||||
// definition to the input components via spread syntax
|
||||
const reducedDefinition = useMemo(() => {
|
||||
return {
|
||||
...definition,
|
||||
...fieldDefinition,
|
||||
onValueChange: undefined,
|
||||
adjustFilters: undefined,
|
||||
adjustValue: undefined,
|
||||
read_only: undefined,
|
||||
children: undefined
|
||||
};
|
||||
}, [definition]);
|
||||
}, [fieldDefinition]);
|
||||
|
||||
// Callback helper when form value changes
|
||||
const onChange = useCallback(
|
||||
@@ -179,11 +196,11 @@ export function ApiFormField({
|
||||
}
|
||||
|
||||
return val;
|
||||
}, [value]);
|
||||
}, [definition.field_type, value]);
|
||||
|
||||
// Coerce the value to a (stringified) boolean value
|
||||
const booleanValue: string = useMemo(() => {
|
||||
return isTrue(value).toString();
|
||||
const booleanValue: boolean = useMemo(() => {
|
||||
return isTrue(value);
|
||||
}, [value]);
|
||||
|
||||
// Construct the individual field
|
||||
@@ -193,7 +210,7 @@ export function ApiFormField({
|
||||
return (
|
||||
<RelatedModelField
|
||||
controller={controller}
|
||||
definition={definition}
|
||||
definition={fieldDefinition}
|
||||
fieldName={fieldName}
|
||||
/>
|
||||
);
|
||||
@@ -201,41 +218,36 @@ export function ApiFormField({
|
||||
case 'url':
|
||||
case 'string':
|
||||
return (
|
||||
<TextInput
|
||||
{...reducedDefinition}
|
||||
ref={field.ref}
|
||||
id={fieldId}
|
||||
aria-label={`text-field-${field.name}`}
|
||||
type={definition.field_type}
|
||||
value={value || ''}
|
||||
error={error?.message}
|
||||
radius="sm"
|
||||
onChange={(event) => onChange(event.currentTarget.value)}
|
||||
rightSection={
|
||||
value && !definition.required ? (
|
||||
<IconX size="1rem" color="red" onClick={() => onChange('')} />
|
||||
) : null
|
||||
}
|
||||
<TextField
|
||||
definition={reducedDefinition}
|
||||
controller={controller}
|
||||
fieldName={fieldName}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
case 'icon':
|
||||
return (
|
||||
<IconField definition={fieldDefinition} controller={controller} />
|
||||
);
|
||||
case 'boolean':
|
||||
return (
|
||||
<Switch
|
||||
{...reducedDefinition}
|
||||
value={booleanValue}
|
||||
checked={booleanValue}
|
||||
ref={ref}
|
||||
id={fieldId}
|
||||
aria-label={`boolean-field-${field.name}`}
|
||||
radius="lg"
|
||||
size="sm"
|
||||
checked={isTrue(value)}
|
||||
error={error?.message}
|
||||
onChange={(event) => onChange(event.currentTarget.checked)}
|
||||
/>
|
||||
);
|
||||
case 'date':
|
||||
case 'datetime':
|
||||
return <DateField controller={controller} definition={definition} />;
|
||||
return (
|
||||
<DateField controller={controller} definition={fieldDefinition} />
|
||||
);
|
||||
case 'integer':
|
||||
case 'decimal':
|
||||
case 'float':
|
||||
@@ -259,7 +271,7 @@ export function ApiFormField({
|
||||
<ChoiceField
|
||||
controller={controller}
|
||||
fieldName={fieldName}
|
||||
definition={definition}
|
||||
definition={fieldDefinition}
|
||||
/>
|
||||
);
|
||||
case 'file upload':
|
||||
@@ -277,15 +289,27 @@ export function ApiFormField({
|
||||
case 'nested object':
|
||||
return (
|
||||
<NestedObjectField
|
||||
definition={definition}
|
||||
definition={fieldDefinition}
|
||||
fieldName={fieldName}
|
||||
control={control}
|
||||
url={url}
|
||||
setFields={setFields}
|
||||
/>
|
||||
);
|
||||
case 'dependent field':
|
||||
return (
|
||||
<DependentField
|
||||
definition={fieldDefinition}
|
||||
fieldName={fieldName}
|
||||
control={control}
|
||||
url={url}
|
||||
setFields={setFields}
|
||||
/>
|
||||
);
|
||||
case 'table':
|
||||
return (
|
||||
<TableField
|
||||
definition={definition}
|
||||
definition={fieldDefinition}
|
||||
fieldName={fieldName}
|
||||
control={controller}
|
||||
/>
|
||||
@@ -293,8 +317,8 @@ export function ApiFormField({
|
||||
default:
|
||||
return (
|
||||
<Alert color="red" title={t`Error`}>
|
||||
Invalid field type for field '{fieldName}': '{definition.field_type}
|
||||
'
|
||||
Invalid field type for field '{fieldName}': '
|
||||
{fieldDefinition.field_type}'
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Select } from '@mantine/core';
|
||||
import { useId } from '@mantine/hooks';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
|
||||
import { ApiFormFieldType } from './ApiFormField';
|
||||
@@ -10,7 +10,8 @@ import { ApiFormFieldType } from './ApiFormField';
|
||||
*/
|
||||
export function ChoiceField({
|
||||
controller,
|
||||
definition
|
||||
definition,
|
||||
fieldName
|
||||
}: {
|
||||
controller: UseControllerReturn<FieldValues, any>;
|
||||
definition: ApiFormFieldType;
|
||||
@@ -23,6 +24,8 @@ export function ChoiceField({
|
||||
fieldState: { error }
|
||||
} = controller;
|
||||
|
||||
const { value } = field;
|
||||
|
||||
// Build a set of choices for the field
|
||||
const choices: any[] = useMemo(() => {
|
||||
let choices = definition.choices ?? [];
|
||||
@@ -48,6 +51,14 @@ export function ChoiceField({
|
||||
[field.onChange, definition]
|
||||
);
|
||||
|
||||
const choiceValue = useMemo(() => {
|
||||
if (!value) {
|
||||
return '';
|
||||
} else {
|
||||
return value.toString();
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
id={fieldId}
|
||||
@@ -57,7 +68,7 @@ export function ChoiceField({
|
||||
{...field}
|
||||
onChange={onChange}
|
||||
data={choices}
|
||||
value={field.value}
|
||||
value={choiceValue}
|
||||
label={definition.label}
|
||||
description={definition.description}
|
||||
placeholder={definition.placeholder}
|
||||
@@ -65,6 +76,7 @@ export function ChoiceField({
|
||||
disabled={definition.disabled}
|
||||
leftSection={definition.icon}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
searchable
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { Control, FieldValues, useFormContext } from 'react-hook-form';
|
||||
|
||||
import { api } from '../../../App';
|
||||
import {
|
||||
constructField,
|
||||
extractAvailableFields
|
||||
} from '../../../functions/forms';
|
||||
import {
|
||||
ApiFormField,
|
||||
ApiFormFieldSet,
|
||||
ApiFormFieldType
|
||||
} from './ApiFormField';
|
||||
|
||||
export function DependentField({
|
||||
control,
|
||||
fieldName,
|
||||
definition,
|
||||
url,
|
||||
setFields
|
||||
}: {
|
||||
control: Control<FieldValues, any>;
|
||||
definition: ApiFormFieldType;
|
||||
fieldName: string;
|
||||
url?: string;
|
||||
setFields?: React.Dispatch<React.SetStateAction<ApiFormFieldSet>>;
|
||||
}) {
|
||||
const { watch, resetField } = useFormContext();
|
||||
|
||||
const mappedFieldNames = useMemo(
|
||||
() =>
|
||||
(definition.depends_on ?? []).map((f) =>
|
||||
[...fieldName.split('.').slice(0, -1), f].join('.')
|
||||
),
|
||||
[definition.depends_on]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const { unsubscribe } = watch(async (values, { name }) => {
|
||||
// subscribe only to the fields that this field depends on
|
||||
if (!name || !mappedFieldNames.includes(name)) return;
|
||||
if (!url || !setFields) return;
|
||||
|
||||
const res = await api.options(url, {
|
||||
data: values // provide the current form state to the API
|
||||
});
|
||||
|
||||
const fields: Record<string, ApiFormFieldType> | null =
|
||||
extractAvailableFields(res, 'POST');
|
||||
|
||||
// update the fields in the form state with the new fields
|
||||
setFields((prevFields) => {
|
||||
const newFields: Record<string, ReturnType<typeof constructField>> = {};
|
||||
|
||||
for (const [k, v] of Object.entries(prevFields)) {
|
||||
newFields[k] = constructField({
|
||||
field: v,
|
||||
definition: fields?.[k]
|
||||
});
|
||||
}
|
||||
|
||||
return newFields;
|
||||
});
|
||||
|
||||
// reset the current field and all nested values with undefined
|
||||
resetField(fieldName, {
|
||||
defaultValue: undefined,
|
||||
keepDirty: true,
|
||||
keepTouched: true
|
||||
});
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [mappedFieldNames, url, setFields, resetField, fieldName]);
|
||||
|
||||
if (!definition.child) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ApiFormField
|
||||
control={control}
|
||||
fieldName={fieldName}
|
||||
definition={definition.child}
|
||||
url={url}
|
||||
setFields={setFields}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import {
|
||||
Box,
|
||||
CloseButton,
|
||||
Combobox,
|
||||
ComboboxStore,
|
||||
Group,
|
||||
Input,
|
||||
InputBase,
|
||||
Select,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
useCombobox
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue, useElementSize } from '@mantine/hooks';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
import Fuse from 'fuse.js';
|
||||
import { startTransition, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
import { FixedSizeGrid as Grid } from 'react-window';
|
||||
|
||||
import { useIconState } from '../../../states/IconState';
|
||||
import { ApiIcon } from '../../items/ApiIcon';
|
||||
import { ApiFormFieldType } from './ApiFormField';
|
||||
|
||||
export default function IconField({
|
||||
controller,
|
||||
definition
|
||||
}: Readonly<{
|
||||
controller: UseControllerReturn<FieldValues, any>;
|
||||
definition: ApiFormFieldType;
|
||||
}>) {
|
||||
const {
|
||||
field,
|
||||
fieldState: { error }
|
||||
} = controller;
|
||||
|
||||
const { value } = field;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const combobox = useCombobox({
|
||||
onOpenedChange: (opened) => setOpen(opened)
|
||||
});
|
||||
|
||||
return (
|
||||
<Combobox store={combobox}>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
label={definition.label}
|
||||
description={definition.description}
|
||||
required={definition.required}
|
||||
error={error?.message}
|
||||
ref={field.ref}
|
||||
component="button"
|
||||
type="button"
|
||||
pointer
|
||||
rightSection={
|
||||
value !== null && !definition.required ? (
|
||||
<CloseButton
|
||||
size="sm"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => field.onChange(null)}
|
||||
/>
|
||||
) : (
|
||||
<Combobox.Chevron />
|
||||
)
|
||||
}
|
||||
onClick={() => combobox.toggleDropdown()}
|
||||
rightSectionPointerEvents={value === null ? 'none' : 'all'}
|
||||
>
|
||||
{field.value ? (
|
||||
<Group gap="xs">
|
||||
<ApiIcon name={field.value} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{field.value}
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Input.Placeholder>
|
||||
<Trans>No icon selected</Trans>
|
||||
</Input.Placeholder>
|
||||
)}
|
||||
</InputBase>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<ComboboxDropdown
|
||||
definition={definition}
|
||||
value={value}
|
||||
combobox={combobox}
|
||||
onChange={field.onChange}
|
||||
open={open}
|
||||
/>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
||||
type RenderIconType = {
|
||||
package: string;
|
||||
name: string;
|
||||
tags: string[];
|
||||
category: string;
|
||||
variant: string;
|
||||
};
|
||||
|
||||
function ComboboxDropdown({
|
||||
definition,
|
||||
value,
|
||||
combobox,
|
||||
onChange,
|
||||
open
|
||||
}: Readonly<{
|
||||
definition: ApiFormFieldType;
|
||||
value: null | string;
|
||||
combobox: ComboboxStore;
|
||||
onChange: (newVal: string | null) => void;
|
||||
open: boolean;
|
||||
}>) {
|
||||
const iconPacks = useIconState((s) => s.packages);
|
||||
const icons = useMemo<RenderIconType[]>(() => {
|
||||
return iconPacks.flatMap((pack) =>
|
||||
Object.entries(pack.icons).flatMap(([name, icon]) =>
|
||||
Object.entries(icon.variants).map(([variant]) => ({
|
||||
package: pack.prefix,
|
||||
name: `${pack.prefix}:${name}:${variant}`,
|
||||
tags: icon.tags,
|
||||
category: icon.category,
|
||||
variant: variant
|
||||
}))
|
||||
)
|
||||
);
|
||||
}, [iconPacks]);
|
||||
const filter = useMemo(
|
||||
() =>
|
||||
new Fuse(icons, {
|
||||
threshold: 0.2,
|
||||
keys: ['name', 'tags', 'category', 'variant']
|
||||
}),
|
||||
[icons]
|
||||
);
|
||||
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [debouncedSearchValue] = useDebouncedValue(searchValue, 200);
|
||||
const [category, setCategory] = useState<string | null>(null);
|
||||
const [pack, setPack] = useState<string | null>(null);
|
||||
|
||||
const categories = useMemo(
|
||||
() =>
|
||||
Array.from(
|
||||
new Set(
|
||||
icons
|
||||
.filter((i) => (pack !== null ? i.package === pack : true))
|
||||
.map((i) => i.category)
|
||||
)
|
||||
).map((x) =>
|
||||
x === ''
|
||||
? { value: '', label: t`Uncategorized` }
|
||||
: { value: x, label: x }
|
||||
),
|
||||
[icons, pack]
|
||||
);
|
||||
const packs = useMemo(
|
||||
() => iconPacks.map((pack) => ({ value: pack.prefix, label: pack.name })),
|
||||
[iconPacks]
|
||||
);
|
||||
|
||||
const applyFilters = (
|
||||
iconList: RenderIconType[],
|
||||
category: string | null,
|
||||
pack: string | null
|
||||
) => {
|
||||
if (category === null && pack === null) return iconList;
|
||||
return iconList.filter(
|
||||
(i) =>
|
||||
(category !== null ? i.category === category : true) &&
|
||||
(pack !== null ? i.package === pack : true)
|
||||
);
|
||||
};
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
if (!debouncedSearchValue) {
|
||||
return applyFilters(icons, category, pack);
|
||||
}
|
||||
|
||||
const res = filter.search(debouncedSearchValue.trim()).map((r) => r.item);
|
||||
|
||||
return applyFilters(res, category, pack);
|
||||
}, [debouncedSearchValue, filter, category, pack]);
|
||||
|
||||
// Reset category when pack changes and the current category is not available in the new pack
|
||||
useEffect(() => {
|
||||
if (value === null) return;
|
||||
|
||||
if (!categories.find((c) => c.value === category)) {
|
||||
setCategory(null);
|
||||
}
|
||||
}, [pack]);
|
||||
|
||||
const { width, ref } = useElementSize();
|
||||
|
||||
return (
|
||||
<Stack gap={6} ref={ref}>
|
||||
<Group gap={4}>
|
||||
<TextInput
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.currentTarget.value)}
|
||||
placeholder={t`Search...`}
|
||||
rightSection={
|
||||
searchValue && !definition.required ? (
|
||||
<IconX size="1rem" onClick={() => setSearchValue('')} />
|
||||
) : null
|
||||
}
|
||||
flex={1}
|
||||
/>
|
||||
<Select
|
||||
value={category}
|
||||
onChange={(c) => startTransition(() => setCategory(c))}
|
||||
data={categories}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
clearable
|
||||
placeholder={t`Select category`}
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={pack}
|
||||
onChange={(c) => startTransition(() => setPack(c))}
|
||||
data={packs}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
clearable
|
||||
placeholder={t`Select pack`}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Text size="sm" c="dimmed" ta="center" mt={-4}>
|
||||
<Trans>{filteredIcons.length} icons</Trans>
|
||||
</Text>
|
||||
|
||||
<DropdownList
|
||||
icons={filteredIcons}
|
||||
onChange={onChange}
|
||||
combobox={combobox}
|
||||
value={value}
|
||||
width={width}
|
||||
open={open}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownList({
|
||||
icons,
|
||||
onChange,
|
||||
combobox,
|
||||
value,
|
||||
width,
|
||||
open
|
||||
}: Readonly<{
|
||||
icons: RenderIconType[];
|
||||
onChange: (newVal: string | null) => void;
|
||||
combobox: ComboboxStore;
|
||||
value: string | null;
|
||||
width: number;
|
||||
open: boolean;
|
||||
}>) {
|
||||
// Get the inner width of the dropdown (excluding the scrollbar) by using the outerRef provided by the react-window Grid element
|
||||
const { width: innerWidth, ref: innerRef } = useElementSize();
|
||||
|
||||
const columnCount = Math.floor(innerWidth / 35);
|
||||
const rowCount = columnCount > 0 ? Math.ceil(icons.length / columnCount) : 0;
|
||||
|
||||
const gridRef = useRef<Grid>(null);
|
||||
const hasScrolledToPositionRef = useRef(true);
|
||||
|
||||
// Reset the has already scrolled to position state when the dropdown open state is changed
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
hasScrolledToPositionRef.current = false;
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [open]);
|
||||
|
||||
// Scroll to the selected icon if not already has scrolled to position
|
||||
useEffect(() => {
|
||||
// Do not scroll if the value is not set, columnCount is not set, the dropdown is not open, or the position has already been scrolled to
|
||||
if (
|
||||
!value ||
|
||||
columnCount === 0 ||
|
||||
hasScrolledToPositionRef.current ||
|
||||
!open
|
||||
)
|
||||
return;
|
||||
|
||||
const iconIdx = icons.findIndex((i) => i.name === value);
|
||||
if (iconIdx === -1) return;
|
||||
|
||||
gridRef.current?.scrollToItem({
|
||||
align: 'start',
|
||||
rowIndex: Math.floor(iconIdx / columnCount)
|
||||
});
|
||||
hasScrolledToPositionRef.current = true;
|
||||
}, [value, columnCount, open]);
|
||||
|
||||
return (
|
||||
<Grid
|
||||
height={200}
|
||||
width={width}
|
||||
rowCount={rowCount}
|
||||
columnCount={columnCount}
|
||||
rowHeight={35}
|
||||
columnWidth={35}
|
||||
itemData={icons}
|
||||
outerRef={innerRef}
|
||||
ref={gridRef}
|
||||
>
|
||||
{({ columnIndex, rowIndex, data, style }) => {
|
||||
const icon = data[rowIndex * columnCount + columnIndex];
|
||||
|
||||
// Grid has empty cells in the last row if the number of icons is not a multiple of columnCount
|
||||
if (icon === undefined) return null;
|
||||
|
||||
const isSelected = value === icon.name;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={icon.name}
|
||||
title={icon.name}
|
||||
onClick={() => {
|
||||
onChange(isSelected ? null : icon.name);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
background: isSelected
|
||||
? 'var(--mantine-color-blue-filled)'
|
||||
: 'unset',
|
||||
borderRadius: 'var(--mantine-radius-default)',
|
||||
...style
|
||||
}}
|
||||
>
|
||||
<ApiIcon name={icon.name} size={24} />
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,24 @@
|
||||
import { Accordion, Divider, Stack, Text } from '@mantine/core';
|
||||
import { Control, FieldValues } from 'react-hook-form';
|
||||
|
||||
import { ApiFormField, ApiFormFieldType } from './ApiFormField';
|
||||
import {
|
||||
ApiFormField,
|
||||
ApiFormFieldSet,
|
||||
ApiFormFieldType
|
||||
} from './ApiFormField';
|
||||
|
||||
export function NestedObjectField({
|
||||
control,
|
||||
fieldName,
|
||||
definition
|
||||
definition,
|
||||
url,
|
||||
setFields
|
||||
}: {
|
||||
control: Control<FieldValues, any>;
|
||||
definition: ApiFormFieldType;
|
||||
fieldName: string;
|
||||
url?: string;
|
||||
setFields?: React.Dispatch<React.SetStateAction<ApiFormFieldSet>>;
|
||||
}) {
|
||||
return (
|
||||
<Accordion defaultValue={'OpenByDefault'} variant="contained">
|
||||
@@ -28,6 +36,8 @@ export function NestedObjectField({
|
||||
fieldName={`${fieldName}.${childFieldName}`}
|
||||
definition={field}
|
||||
control={control}
|
||||
url={url}
|
||||
setFields={setFields}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -236,25 +236,23 @@ export function RelatedModelField({
|
||||
// Field doesn't follow Mantine theming
|
||||
// Define color theme to pass to field based on Mantine theme
|
||||
const theme = useMantineTheme();
|
||||
|
||||
const colorschema = vars.colors.primaryColors;
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
const colors = useMemo(() => {
|
||||
let colors: any;
|
||||
if (colorScheme === 'dark') {
|
||||
colors = {
|
||||
neutral0: colorschema[6],
|
||||
neutral5: colorschema[4],
|
||||
neutral10: colorschema[4],
|
||||
neutral20: colorschema[4],
|
||||
neutral30: colorschema[3],
|
||||
neutral40: colorschema[2],
|
||||
neutral50: colorschema[1],
|
||||
neutral60: colorschema[0],
|
||||
neutral70: colorschema[0],
|
||||
neutral80: colorschema[0],
|
||||
neutral90: colorschema[0],
|
||||
neutral0: vars.colors.dark[6],
|
||||
neutral5: vars.colors.dark[4],
|
||||
neutral10: vars.colors.dark[4],
|
||||
neutral20: vars.colors.dark[4],
|
||||
neutral30: vars.colors.dark[3],
|
||||
neutral40: vars.colors.dark[2],
|
||||
neutral50: vars.colors.dark[1],
|
||||
neutral60: vars.colors.dark[0],
|
||||
neutral70: vars.colors.dark[0],
|
||||
neutral80: vars.colors.dark[0],
|
||||
neutral90: vars.colors.dark[0],
|
||||
primary: vars.colors.primaryColors[7],
|
||||
primary25: vars.colors.primaryColors[6],
|
||||
primary50: vars.colors.primaryColors[5],
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import { Container, Flex, Group, Table } from '@mantine/core';
|
||||
import { Container, Group, Table } from '@mantine/core';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
|
||||
import { InvenTreeIcon } from '../../../functions/icons';
|
||||
import { StandaloneField } from '../StandaloneField';
|
||||
import { ApiFormFieldType } from './ApiFormField';
|
||||
|
||||
export function TableField({
|
||||
@@ -83,23 +85,51 @@ export function TableField({
|
||||
|
||||
/*
|
||||
* Display an "extra" row below the main table row, for additional information.
|
||||
* - Each "row" can display an extra row of information below the main row
|
||||
*/
|
||||
export function TableFieldExtraRow({
|
||||
visible,
|
||||
content,
|
||||
colSpan
|
||||
fieldDefinition,
|
||||
defaultValue,
|
||||
emptyValue,
|
||||
onValueChange
|
||||
}: {
|
||||
visible: boolean;
|
||||
content: React.ReactNode;
|
||||
colSpan?: number;
|
||||
fieldDefinition: ApiFormFieldType;
|
||||
defaultValue?: any;
|
||||
emptyValue?: any;
|
||||
onValueChange: (value: any) => void;
|
||||
}) {
|
||||
// Callback whenever the visibility of the sub-field changes
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
// If the sub-field is hidden, reset the value to the "empty" value
|
||||
onValueChange(emptyValue);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const field: ApiFormFieldType = useMemo(() => {
|
||||
return {
|
||||
...fieldDefinition,
|
||||
default: defaultValue,
|
||||
onValueChange: (value: any) => {
|
||||
onValueChange(value);
|
||||
}
|
||||
};
|
||||
}, [fieldDefinition]);
|
||||
|
||||
return (
|
||||
visible && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={colSpan ?? 3}>
|
||||
<Group justify="flex-start" grow>
|
||||
<InvenTreeIcon icon="downright" />
|
||||
{content}
|
||||
<Table.Td colSpan={10}>
|
||||
<Group grow preventGrowOverflow={false} justify="flex-apart" p="xs">
|
||||
<Container flex={0} p="xs">
|
||||
<InvenTreeIcon icon="downright" />
|
||||
</Container>
|
||||
<StandaloneField
|
||||
fieldDefinition={field}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { TextInput } from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useId, useState } from 'react';
|
||||
import { FieldValues, UseControllerReturn } from 'react-hook-form';
|
||||
|
||||
/*
|
||||
* Custom implementation of the mantine <TextInput> component,
|
||||
* used for rendering text input fields in forms.
|
||||
* Uses a debounced value to prevent excessive re-renders.
|
||||
*/
|
||||
export default function TextField({
|
||||
controller,
|
||||
fieldName,
|
||||
definition,
|
||||
onChange
|
||||
}: {
|
||||
controller: UseControllerReturn<FieldValues, any>;
|
||||
definition: any;
|
||||
fieldName: string;
|
||||
onChange: (value: any) => void;
|
||||
}) {
|
||||
const fieldId = useId();
|
||||
const {
|
||||
field,
|
||||
fieldState: { error }
|
||||
} = controller;
|
||||
|
||||
const { value } = field;
|
||||
|
||||
const [rawText, setRawText] = useState(value);
|
||||
const [debouncedText] = useDebouncedValue(rawText, 250);
|
||||
|
||||
useEffect(() => {
|
||||
setRawText(value);
|
||||
}, [value]);
|
||||
|
||||
const onTextChange = useCallback((value: any) => {
|
||||
setRawText(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedText !== value) {
|
||||
onChange(debouncedText);
|
||||
}
|
||||
}, [debouncedText]);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
{...definition}
|
||||
ref={field.ref}
|
||||
id={fieldId}
|
||||
aria-label={`text-field-${field.name}`}
|
||||
type={definition.field_type}
|
||||
value={rawText || ''}
|
||||
error={error?.message}
|
||||
radius="sm"
|
||||
onChange={(event) => onTextChange(event.currentTarget.value)}
|
||||
onBlur={(event) => {
|
||||
onChange(event.currentTarget.value);
|
||||
}}
|
||||
rightSection={
|
||||
value && !definition.required ? (
|
||||
<IconX size="1rem" color="red" onClick={() => onTextChange('')} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -50,49 +50,3 @@ export function Thumbnail({
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function ThumbnailHoverCard({
|
||||
src,
|
||||
text,
|
||||
link = '',
|
||||
alt = t`Thumbnail`,
|
||||
size = 20
|
||||
}: {
|
||||
src: string;
|
||||
text: string;
|
||||
link?: string;
|
||||
alt?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const card = useMemo(() => {
|
||||
return (
|
||||
<Group justify="left" gap={10} wrap="nowrap">
|
||||
<Thumbnail src={src} alt={alt} size={size} />
|
||||
<Text>{text}</Text>
|
||||
</Group>
|
||||
);
|
||||
}, [src, text, alt, size]);
|
||||
|
||||
if (link) {
|
||||
return (
|
||||
<Anchor href={link} style={{ textDecoration: 'none' }}>
|
||||
{card}
|
||||
</Anchor>
|
||||
);
|
||||
}
|
||||
|
||||
return <div>{card}</div>;
|
||||
}
|
||||
|
||||
export function PartHoverCard({ part }: { part: any }) {
|
||||
return part ? (
|
||||
<ThumbnailHoverCard
|
||||
src={part.thumbnail || part.image}
|
||||
text={part.full_name}
|
||||
alt={part.description}
|
||||
link=""
|
||||
/>
|
||||
) : (
|
||||
<Skeleton />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Group, HoverCard, Paper, Space, Stack, Text } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconCircleCheck,
|
||||
IconCircleDashedCheck,
|
||||
IconExclamationCircle
|
||||
} from '@tabler/icons-react';
|
||||
import { ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { cancelEvent } from '../../functions/events';
|
||||
import {
|
||||
useDeleteApiFormModal,
|
||||
useEditApiFormModal
|
||||
} from '../../hooks/UseForm';
|
||||
import { ImportSessionState } from '../../hooks/UseImportSession';
|
||||
import { useTable } from '../../hooks/UseTable';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { TableColumn } from '../../tables/Column';
|
||||
import { TableFilter } from '../../tables/Filter';
|
||||
import { InvenTreeTable } from '../../tables/InvenTreeTable';
|
||||
import { RowDeleteAction, RowEditAction } from '../../tables/RowActions';
|
||||
import { ActionButton } from '../buttons/ActionButton';
|
||||
import { YesNoButton } from '../buttons/YesNoButton';
|
||||
import { ApiFormFieldSet } from '../forms/fields/ApiFormField';
|
||||
import { ProgressBar } from '../items/ProgressBar';
|
||||
import { RenderRemoteInstance } from '../render/Instance';
|
||||
|
||||
function ImporterDataCell({
|
||||
session,
|
||||
column,
|
||||
row,
|
||||
onEdit
|
||||
}: {
|
||||
session: ImportSessionState;
|
||||
column: any;
|
||||
row: any;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const onRowEdit = useCallback(
|
||||
(event: any) => {
|
||||
cancelEvent(event);
|
||||
|
||||
if (!row.complete) {
|
||||
onEdit?.();
|
||||
}
|
||||
},
|
||||
[onEdit, row]
|
||||
);
|
||||
|
||||
const cellErrors: string[] = useMemo(() => {
|
||||
if (!row.errors) {
|
||||
return [];
|
||||
}
|
||||
return row?.errors[column.field] ?? [];
|
||||
}, [row.errors, column.field]);
|
||||
|
||||
const cellValue: ReactNode = useMemo(() => {
|
||||
let field_def = session.availableFields[column.field];
|
||||
|
||||
if (!row?.data) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
switch (field_def?.type) {
|
||||
case 'boolean':
|
||||
return (
|
||||
<YesNoButton value={row.data ? row.data[column.field] : false} />
|
||||
);
|
||||
case 'related field':
|
||||
if (field_def.model && row.data[column.field]) {
|
||||
return (
|
||||
<RenderRemoteInstance
|
||||
model={field_def.model}
|
||||
pk={row.data[column.field]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
let value = row.data ? row.data[column.field] ?? '' : '';
|
||||
|
||||
if (!value) {
|
||||
value = '-';
|
||||
}
|
||||
|
||||
return value;
|
||||
}, [row.data, column.field, session.availableFields]);
|
||||
|
||||
const cellValid: boolean = useMemo(
|
||||
() => cellErrors.length == 0,
|
||||
[cellErrors]
|
||||
);
|
||||
|
||||
return (
|
||||
<HoverCard disabled={cellValid} openDelay={100} closeDelay={100}>
|
||||
<HoverCard.Target>
|
||||
<Group grow justify="apart" onClick={onRowEdit}>
|
||||
<Group grow style={{ flex: 1 }}>
|
||||
<Text size="xs" c={cellValid ? undefined : 'red'}>
|
||||
{cellValue}
|
||||
</Text>
|
||||
</Group>
|
||||
</Group>
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Stack gap="xs">
|
||||
{cellErrors.map((error: string) => (
|
||||
<Text size="xs" c="red" key={error}>
|
||||
{error}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImporterDataSelector({
|
||||
session
|
||||
}: {
|
||||
session: ImportSessionState;
|
||||
}) {
|
||||
const table = useTable('dataimporter');
|
||||
|
||||
const [selectedFieldNames, setSelectedFieldNames] = useState<string[]>([]);
|
||||
|
||||
const selectedFields: ApiFormFieldSet = useMemo(() => {
|
||||
let fields: ApiFormFieldSet = {};
|
||||
|
||||
for (let field of selectedFieldNames) {
|
||||
// Find the field definition in session.availableFields
|
||||
let fieldDef = session.availableFields[field];
|
||||
if (fieldDef) {
|
||||
// Construct field filters based on session field filters
|
||||
let filters = fieldDef.filters ?? {};
|
||||
|
||||
if (session.fieldFilters[field]) {
|
||||
filters = {
|
||||
...filters,
|
||||
...session.fieldFilters[field]
|
||||
};
|
||||
}
|
||||
|
||||
fields[field] = {
|
||||
...fieldDef,
|
||||
field_type: fieldDef.type,
|
||||
description: fieldDef.help_text,
|
||||
filters: filters
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [selectedFieldNames, session.availableFields, session.fieldFilters]);
|
||||
|
||||
const importData = useCallback(
|
||||
(rows: number[]) => {
|
||||
notifications.show({
|
||||
title: t`Importing Rows`,
|
||||
message: t`Please wait while the data is imported`,
|
||||
autoClose: false,
|
||||
color: 'blue',
|
||||
id: 'importing-rows',
|
||||
icon: <IconArrowRight />
|
||||
});
|
||||
|
||||
api
|
||||
.post(
|
||||
apiUrl(ApiEndpoints.import_session_accept_rows, session.sessionId),
|
||||
{
|
||||
rows: rows
|
||||
}
|
||||
)
|
||||
.catch(() => {
|
||||
notifications.show({
|
||||
title: t`Error`,
|
||||
message: t`An error occurred while importing data`,
|
||||
color: 'red',
|
||||
autoClose: true
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
table.clearSelectedRecords();
|
||||
notifications.hide('importing-rows');
|
||||
table.refreshTable();
|
||||
|
||||
session.refreshSession();
|
||||
});
|
||||
},
|
||||
[session.sessionId, table.refreshTable]
|
||||
);
|
||||
|
||||
const [selectedRow, setSelectedRow] = useState<any>({});
|
||||
|
||||
const editRow = useEditApiFormModal({
|
||||
url: ApiEndpoints.import_session_row_list,
|
||||
pk: selectedRow.pk,
|
||||
title: t`Edit Data`,
|
||||
fields: selectedFields,
|
||||
initialData: selectedRow.data,
|
||||
fetchInitialData: false,
|
||||
processFormData: (data: any) => {
|
||||
// Construct fields back into a single object
|
||||
return {
|
||||
data: {
|
||||
...selectedRow.data,
|
||||
...data
|
||||
}
|
||||
};
|
||||
},
|
||||
onFormSuccess: (row: any) => table.updateRecord(row)
|
||||
});
|
||||
|
||||
const editCell = useCallback(
|
||||
(row: any, col: any) => {
|
||||
setSelectedRow(row);
|
||||
setSelectedFieldNames([col.field]);
|
||||
editRow.open();
|
||||
},
|
||||
[session, editRow]
|
||||
);
|
||||
|
||||
const deleteRow = useDeleteApiFormModal({
|
||||
url: ApiEndpoints.import_session_row_list,
|
||||
pk: selectedRow.pk,
|
||||
title: t`Delete Row`,
|
||||
onFormSuccess: () => table.refreshTable()
|
||||
});
|
||||
|
||||
const rowErrors = useCallback((row: any) => {
|
||||
if (!row.errors) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let errors: string[] = [];
|
||||
|
||||
for (const k of Object.keys(row.errors)) {
|
||||
if (row.errors[k]) {
|
||||
if (Array.isArray(row.errors[k])) {
|
||||
row.errors[k].forEach((e: string) => {
|
||||
errors.push(`${k}: ${e}`);
|
||||
});
|
||||
} else {
|
||||
errors.push(row.errors[k].toString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}, []);
|
||||
|
||||
const columns: TableColumn[] = useMemo(() => {
|
||||
let columns: TableColumn[] = [
|
||||
{
|
||||
accessor: 'row_index',
|
||||
title: t`Row`,
|
||||
sortable: true,
|
||||
switchable: false,
|
||||
render: (row: any) => {
|
||||
return (
|
||||
<Group justify="left" gap="xs">
|
||||
<Text size="sm">{row.row_index}</Text>
|
||||
{row.complete && <IconCircleCheck color="green" size={16} />}
|
||||
{!row.complete && row.valid && (
|
||||
<IconCircleDashedCheck color="blue" size={16} />
|
||||
)}
|
||||
{!row.complete && !row.valid && (
|
||||
<HoverCard openDelay={50} closeDelay={100}>
|
||||
<HoverCard.Target>
|
||||
<IconExclamationCircle color="red" size={16} />
|
||||
</HoverCard.Target>
|
||||
<HoverCard.Dropdown>
|
||||
<Stack gap="xs">
|
||||
<Text>{t`Row contains errors`}:</Text>
|
||||
{rowErrors(row).map((error: string) => (
|
||||
<Text size="sm" c="red" key={error}>
|
||||
{error}
|
||||
</Text>
|
||||
))}
|
||||
</Stack>
|
||||
</HoverCard.Dropdown>
|
||||
</HoverCard>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
},
|
||||
...session.mappedFields.map((column: any) => {
|
||||
return {
|
||||
accessor: column.field,
|
||||
title: column.column ?? column.title,
|
||||
sortable: false,
|
||||
switchable: true,
|
||||
render: (row: any) => {
|
||||
return (
|
||||
<ImporterDataCell
|
||||
session={session}
|
||||
column={column}
|
||||
row={row}
|
||||
onEdit={() => editCell(row, column)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
return columns;
|
||||
}, [session]);
|
||||
|
||||
const rowActions = useCallback(
|
||||
(record: any) => {
|
||||
return [
|
||||
{
|
||||
title: t`Accept`,
|
||||
icon: <IconArrowRight />,
|
||||
color: 'green',
|
||||
hidden: record.complete || !record.valid,
|
||||
onClick: () => {
|
||||
importData([record.pk]);
|
||||
}
|
||||
},
|
||||
RowEditAction({
|
||||
hidden: record.complete,
|
||||
onClick: () => {
|
||||
setSelectedRow(record);
|
||||
setSelectedFieldNames(
|
||||
session.mappedFields.map((f: any) => f.field)
|
||||
);
|
||||
editRow.open();
|
||||
}
|
||||
}),
|
||||
RowDeleteAction({
|
||||
onClick: () => {
|
||||
setSelectedRow(record);
|
||||
deleteRow.open();
|
||||
}
|
||||
})
|
||||
];
|
||||
},
|
||||
[session, importData]
|
||||
);
|
||||
|
||||
const filters: TableFilter[] = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
name: 'valid',
|
||||
label: t`Valid`,
|
||||
description: t`Filter by row validation status`,
|
||||
type: 'boolean'
|
||||
},
|
||||
{
|
||||
name: 'complete',
|
||||
label: t`Complete`,
|
||||
description: t`Filter by row completion status`,
|
||||
type: 'boolean'
|
||||
}
|
||||
];
|
||||
}, []);
|
||||
|
||||
const tableActions = useMemo(() => {
|
||||
// Can only "import" valid (and incomplete) rows
|
||||
const canImport: boolean =
|
||||
table.hasSelectedRecords &&
|
||||
table.selectedRecords.every((row: any) => row.valid && !row.complete);
|
||||
|
||||
return [
|
||||
<ActionButton
|
||||
disabled={!canImport}
|
||||
icon={<IconArrowRight />}
|
||||
color="green"
|
||||
tooltip={t`Import selected rows`}
|
||||
onClick={() => {
|
||||
importData(table.selectedRecords.map((row: any) => row.pk));
|
||||
}}
|
||||
/>
|
||||
];
|
||||
}, [table.hasSelectedRecords, table.selectedRecords]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{editRow.modal}
|
||||
{deleteRow.modal}
|
||||
<Stack gap="xs">
|
||||
<Paper shadow="xs" p="xs">
|
||||
<Group grow justify="apart">
|
||||
<Text size="lg">{t`Processing Data`}</Text>
|
||||
<Space />
|
||||
<ProgressBar
|
||||
maximum={session.rowCount}
|
||||
value={session.completedRowCount}
|
||||
progressLabel
|
||||
/>
|
||||
<Space />
|
||||
</Group>
|
||||
</Paper>
|
||||
<InvenTreeTable
|
||||
tableState={table}
|
||||
columns={columns}
|
||||
url={apiUrl(ApiEndpoints.import_session_row_list)}
|
||||
props={{
|
||||
params: {
|
||||
session: session.sessionId
|
||||
},
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions,
|
||||
tableFilters: filters,
|
||||
enableColumnSwitching: true,
|
||||
enableColumnCaching: false,
|
||||
enableSelection: true,
|
||||
enableBulkDelete: true,
|
||||
afterBulkDelete: () => {
|
||||
session.refreshSession();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Select,
|
||||
Space,
|
||||
Stack,
|
||||
Table,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ImportSessionState } from '../../hooks/UseImportSession';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { StandaloneField } from '../forms/StandaloneField';
|
||||
import { ApiFormFieldType } from '../forms/fields/ApiFormField';
|
||||
|
||||
function ImporterColumn({ column, options }: { column: any; options: any[] }) {
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
const [selectedColumn, setSelectedColumn] = useState<string>(
|
||||
column.column ?? ''
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedColumn(column.column ?? '');
|
||||
}, [column.column]);
|
||||
|
||||
const onChange = useCallback(
|
||||
(value: any) => {
|
||||
api
|
||||
.patch(
|
||||
apiUrl(ApiEndpoints.import_session_column_mapping_list, column.pk),
|
||||
{
|
||||
column: value || ''
|
||||
}
|
||||
)
|
||||
.then((response) => {
|
||||
setSelectedColumn(response.data?.column ?? value);
|
||||
setErrorMessage('');
|
||||
})
|
||||
.catch((error) => {
|
||||
const data = error.response.data;
|
||||
setErrorMessage(
|
||||
data.column ?? data.non_field_errors ?? t`An error occurred`
|
||||
);
|
||||
});
|
||||
},
|
||||
[column]
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
error={errorMessage}
|
||||
clearable
|
||||
searchable
|
||||
placeholder={t`Select column, or leave blank to ignore this field.`}
|
||||
label={undefined}
|
||||
data={options}
|
||||
value={selectedColumn}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ImporterDefaultField({
|
||||
fieldName,
|
||||
session
|
||||
}: {
|
||||
fieldName: string;
|
||||
session: ImportSessionState;
|
||||
}) {
|
||||
const onChange = useCallback(
|
||||
(value: any) => {
|
||||
// Update the default value for the field
|
||||
let defaults = {
|
||||
...session.fieldDefaults,
|
||||
[fieldName]: value
|
||||
};
|
||||
|
||||
api
|
||||
.patch(apiUrl(ApiEndpoints.import_session_list, session.sessionId), {
|
||||
field_defaults: defaults
|
||||
})
|
||||
.then((response: any) => {
|
||||
session.setSessionData(response.data);
|
||||
})
|
||||
.catch(() => {
|
||||
// TODO: Error message?
|
||||
});
|
||||
},
|
||||
[fieldName, session, session.fieldDefaults]
|
||||
);
|
||||
|
||||
const fieldDef: ApiFormFieldType = useMemo(() => {
|
||||
let def: any = session.availableFields[fieldName];
|
||||
|
||||
if (def) {
|
||||
def = {
|
||||
...def,
|
||||
value: session.fieldDefaults[fieldName],
|
||||
field_type: def.type,
|
||||
description: def.help_text,
|
||||
onValueChange: onChange
|
||||
};
|
||||
}
|
||||
|
||||
return def;
|
||||
}, [fieldName, session.availableFields, session.fieldDefaults]);
|
||||
|
||||
return (
|
||||
fieldDef && <StandaloneField fieldDefinition={fieldDef} hideLabels={true} />
|
||||
);
|
||||
}
|
||||
|
||||
function ImporterColumnTableRow({
|
||||
session,
|
||||
column,
|
||||
options
|
||||
}: {
|
||||
session: ImportSessionState;
|
||||
column: any;
|
||||
options: any;
|
||||
}) {
|
||||
return (
|
||||
<Table.Tr key={column.label ?? column.field}>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Text fw={column.required ? 700 : undefined}>
|
||||
{column.label ?? column.field}
|
||||
</Text>
|
||||
{column.required && (
|
||||
<Text c="red" fw={700}>
|
||||
*
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{column.description}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ImporterColumn column={column} options={options} />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<ImporterDefaultField fieldName={column.field} session={session} />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImporterColumnSelector({
|
||||
session
|
||||
}: {
|
||||
session: ImportSessionState;
|
||||
}) {
|
||||
const [errorMessage, setErrorMessage] = useState<string>('');
|
||||
|
||||
const acceptMapping = useCallback(() => {
|
||||
const url = apiUrl(
|
||||
ApiEndpoints.import_session_accept_fields,
|
||||
session.sessionId
|
||||
);
|
||||
|
||||
api
|
||||
.post(url)
|
||||
.then(() => {
|
||||
session.refreshSession();
|
||||
})
|
||||
.catch((error) => {
|
||||
setErrorMessage(error.response?.data?.error ?? t`An error occurred`);
|
||||
});
|
||||
}, [session.sessionId]);
|
||||
|
||||
const columnOptions: any[] = useMemo(() => {
|
||||
return [
|
||||
{ value: '', label: t`Ignore this field` },
|
||||
...session.availableColumns.map((column: any) => {
|
||||
return {
|
||||
value: column,
|
||||
label: column
|
||||
};
|
||||
})
|
||||
];
|
||||
}, [session.availableColumns]);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Paper shadow="xs" p="xs">
|
||||
<Group grow justify="apart">
|
||||
<Text size="lg">{t`Mapping data columns to database fields`}</Text>
|
||||
<Space />
|
||||
<Button color="green" variant="filled" onClick={acceptMapping}>
|
||||
<Group>
|
||||
<IconCheck />
|
||||
{t`Accept Column Mapping`}
|
||||
</Group>
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
{errorMessage && (
|
||||
<Alert color="red" title={t`Error`}>
|
||||
<Text>{errorMessage}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t`Database Field`}</Table.Th>
|
||||
<Table.Th>{t`Field Description`}</Table.Th>
|
||||
<Table.Th>{t`Imported Column`}</Table.Th>
|
||||
<Table.Th>{t`Default Value`}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{session.columnMappings.map((column: any) => {
|
||||
return (
|
||||
<ImporterColumnTableRow
|
||||
session={session}
|
||||
column={column}
|
||||
options={columnOptions}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
Loader,
|
||||
LoadingOverlay,
|
||||
Paper,
|
||||
Space,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { useImportSession } from '../../hooks/UseImportSession';
|
||||
import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
import ImporterDataSelector from './ImportDataSelector';
|
||||
import ImporterColumnSelector from './ImporterColumnSelector';
|
||||
import ImporterImportProgress from './ImporterImportProgress';
|
||||
|
||||
/*
|
||||
* Stepper component showing the current step of the data import process.
|
||||
*/
|
||||
function ImportDrawerStepper({ currentStep }: { currentStep: number }) {
|
||||
/* TODO: Enhance this with:
|
||||
* - Custom icons
|
||||
* - Loading indicators for "background" states
|
||||
*/
|
||||
|
||||
return (
|
||||
<Stepper
|
||||
active={currentStep}
|
||||
onStepClick={undefined}
|
||||
allowNextStepsSelect={false}
|
||||
iconSize={20}
|
||||
size="xs"
|
||||
>
|
||||
<Stepper.Step label={t`Upload File`} />
|
||||
<Stepper.Step label={t`Map Columns`} />
|
||||
<Stepper.Step label={t`Import Data`} />
|
||||
<Stepper.Step label={t`Process Data`} />
|
||||
<Stepper.Step label={t`Complete Import`} />
|
||||
</Stepper>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ImporterDrawer({
|
||||
sessionId,
|
||||
opened,
|
||||
onClose
|
||||
}: {
|
||||
sessionId: number;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const session = useImportSession({ sessionId: sessionId });
|
||||
|
||||
const importSessionStatus = useStatusCodes({
|
||||
modelType: ModelType.importsession
|
||||
});
|
||||
|
||||
// Map from import steps to stepper steps
|
||||
const currentStep = useMemo(() => {
|
||||
switch (session.status) {
|
||||
default:
|
||||
case importSessionStatus.INITIAL:
|
||||
return 0;
|
||||
case importSessionStatus.MAPPING:
|
||||
return 1;
|
||||
case importSessionStatus.IMPORTING:
|
||||
return 2;
|
||||
case importSessionStatus.PROCESSING:
|
||||
return 3;
|
||||
case importSessionStatus.COMPLETE:
|
||||
return 4;
|
||||
}
|
||||
}, [session.status]);
|
||||
|
||||
const widget = useMemo(() => {
|
||||
if (session.sessionQuery.isLoading || session.sessionQuery.isFetching) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
switch (session.status) {
|
||||
case importSessionStatus.INITIAL:
|
||||
return <Text>Initial : TODO</Text>;
|
||||
case importSessionStatus.MAPPING:
|
||||
return <ImporterColumnSelector session={session} />;
|
||||
case importSessionStatus.IMPORTING:
|
||||
return <ImporterImportProgress session={session} />;
|
||||
case importSessionStatus.PROCESSING:
|
||||
return <ImporterDataSelector session={session} />;
|
||||
case importSessionStatus.COMPLETE:
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Alert
|
||||
color="green"
|
||||
title={t`Import Complete`}
|
||||
icon={<IconCheck />}
|
||||
>
|
||||
{t`Data has been imported successfully`}
|
||||
</Alert>
|
||||
<Button color="blue" onClick={onClose}>{t`Close`}</Button>
|
||||
</Stack>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Alert color="red" title={t`Unknown Status`} icon={<IconCheck />}>
|
||||
{t`Import session has unknown status`}: {session.status}
|
||||
</Alert>
|
||||
<Button color="red" onClick={onClose}>{t`Close`}</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
}, [session.status, session.sessionQuery]);
|
||||
|
||||
const title: ReactNode = useMemo(() => {
|
||||
return (
|
||||
<Stack gap="xs" style={{ width: '100%' }}>
|
||||
<Group
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
justify="space-apart"
|
||||
grow
|
||||
preventGrowOverflow={false}
|
||||
>
|
||||
<StylishText size="lg">
|
||||
{session.sessionData?.statusText ?? t`Importing Data`}
|
||||
</StylishText>
|
||||
<ImportDrawerStepper currentStep={currentStep} />
|
||||
<Space />
|
||||
</Group>
|
||||
<Divider />
|
||||
</Stack>
|
||||
);
|
||||
}, [session.sessionData]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
position="bottom"
|
||||
size="80%"
|
||||
title={title}
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
withCloseButton={true}
|
||||
closeOnEscape={false}
|
||||
closeOnClickOutside={false}
|
||||
styles={{
|
||||
header: {
|
||||
width: '100%'
|
||||
},
|
||||
title: {
|
||||
width: '100%'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack gap="xs">
|
||||
<LoadingOverlay visible={session.sessionQuery.isFetching} />
|
||||
<Paper p="md">{session.sessionQuery.isFetching || widget}</Paper>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Center, Container, Loader, Stack, Text } from '@mantine/core';
|
||||
import { useInterval } from '@mantine/hooks';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { ImportSessionState } from '../../hooks/UseImportSession';
|
||||
import useStatusCodes from '../../hooks/UseStatusCodes';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
|
||||
export default function ImporterImportProgress({
|
||||
session
|
||||
}: {
|
||||
session: ImportSessionState;
|
||||
}) {
|
||||
const importSessionStatus = useStatusCodes({
|
||||
modelType: ModelType.importsession
|
||||
});
|
||||
|
||||
// Periodically refresh the import session data
|
||||
const interval = useInterval(() => {
|
||||
if (session.status == importSessionStatus.IMPORTING) {
|
||||
session.refreshSession();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
useEffect(() => {
|
||||
interval.start();
|
||||
return interval.stop;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Container>
|
||||
<Stack gap="xs">
|
||||
<StylishText size="lg">{t`Importing Records`}</StylishText>
|
||||
<Loader />
|
||||
<Text size="lg">
|
||||
{t`Imported rows`}: {session.sessionData.row_count}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Menu,
|
||||
Tooltip
|
||||
} from '@mantine/core';
|
||||
import { modals } from '@mantine/modals';
|
||||
import {
|
||||
IconCopy,
|
||||
IconEdit,
|
||||
@@ -16,9 +17,11 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import { ReactNode, useMemo } from 'react';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { identifierString } from '../../functions/conversion';
|
||||
import { InvenTreeIcon } from '../../functions/icons';
|
||||
import { notYetImplemented } from '../../functions/notifications';
|
||||
import { InvenTreeQRCode } from './QRCode';
|
||||
|
||||
export type ActionDropdownItem = {
|
||||
icon: ReactNode;
|
||||
@@ -39,12 +42,14 @@ export function ActionDropdown({
|
||||
icon,
|
||||
tooltip,
|
||||
actions,
|
||||
disabled = false
|
||||
disabled = false,
|
||||
hidden = false
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
tooltip: string;
|
||||
actions: ActionDropdownItem[];
|
||||
disabled?: boolean;
|
||||
hidden?: boolean;
|
||||
}) {
|
||||
const hasActions = useMemo(() => {
|
||||
return actions.some((action) => !action.hidden);
|
||||
@@ -58,7 +63,7 @@ export function ActionDropdown({
|
||||
return identifierString(`action-menu-${tooltip}`);
|
||||
}, [tooltip]);
|
||||
|
||||
return hasActions ? (
|
||||
return !hidden && hasActions ? (
|
||||
<Menu position="bottom-end" key={menuName}>
|
||||
<Indicator disabled={!indicatorProps} {...indicatorProps?.indicator}>
|
||||
<Menu.Target>
|
||||
@@ -66,7 +71,7 @@ export function ActionDropdown({
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
radius="sm"
|
||||
variant="outline"
|
||||
variant="transparent"
|
||||
disabled={disabled}
|
||||
aria-label={menuName}
|
||||
>
|
||||
@@ -84,7 +89,11 @@ export function ActionDropdown({
|
||||
{...action.indicator}
|
||||
key={action.name}
|
||||
>
|
||||
<Tooltip label={action.tooltip} hidden={!action.tooltip}>
|
||||
<Tooltip
|
||||
label={action.tooltip}
|
||||
hidden={!action.tooltip}
|
||||
position="left"
|
||||
>
|
||||
<Menu.Item
|
||||
aria-label={id}
|
||||
leftSection={action.icon}
|
||||
@@ -126,11 +135,20 @@ export function BarcodeActionDropdown({
|
||||
// Common action button for viewing a barcode
|
||||
export function ViewBarcodeAction({
|
||||
hidden = false,
|
||||
onClick
|
||||
model,
|
||||
pk
|
||||
}: {
|
||||
hidden?: boolean;
|
||||
onClick?: () => void;
|
||||
model: ModelType;
|
||||
pk: number;
|
||||
}): ActionDropdownItem {
|
||||
const onClick = () => {
|
||||
modals.open({
|
||||
title: t`View Barcode`,
|
||||
children: <InvenTreeQRCode model={model} pk={pk} />
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
icon: <IconQrcode />,
|
||||
name: t`View`,
|
||||
@@ -215,6 +233,24 @@ export function DeleteItemAction({
|
||||
};
|
||||
}
|
||||
|
||||
export function HoldItemAction({
|
||||
hidden = false,
|
||||
tooltip,
|
||||
onClick
|
||||
}: {
|
||||
hidden?: boolean;
|
||||
tooltip?: string;
|
||||
onClick?: () => void;
|
||||
}): ActionDropdownItem {
|
||||
return {
|
||||
icon: <InvenTreeIcon icon="hold" iconProps={{ color: 'orange' }} />,
|
||||
name: t`Hold`,
|
||||
tooltip: tooltip ?? t`Hold`,
|
||||
onClick: onClick,
|
||||
hidden: hidden
|
||||
};
|
||||
}
|
||||
|
||||
export function CancelItemAction({
|
||||
hidden = false,
|
||||
tooltip,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const icon = style({
|
||||
fontStyle: 'normal',
|
||||
fontWeight: 'normal',
|
||||
fontVariant: 'normal',
|
||||
textTransform: 'none',
|
||||
lineHeight: 1,
|
||||
width: 'fit-content',
|
||||
// Better font rendering
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
MozOsxFontSmoothing: 'grayscale'
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useIconState } from '../../states/IconState';
|
||||
import * as classes from './ApiIcon.css';
|
||||
|
||||
type ApiIconProps = {
|
||||
name: string;
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const ApiIcon = ({ name: _name, size = 22 }: ApiIconProps) => {
|
||||
const [iconPackage, name, variant] = _name.split(':');
|
||||
const icon = useIconState(
|
||||
(s) => s.packagesMap[iconPackage]?.['icons'][name]?.['variants'][variant]
|
||||
);
|
||||
const unicode = icon ? String.fromCodePoint(parseInt(icon, 16)) : '';
|
||||
|
||||
return (
|
||||
<i
|
||||
className={classes.icon}
|
||||
style={{
|
||||
fontFamily: `inventree-icon-font-${iconPackage}`,
|
||||
fontSize: size
|
||||
}}
|
||||
>
|
||||
{unicode}
|
||||
</i>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Trans } from '@lingui/macro';
|
||||
import { Carousel } from '@mantine/carousel';
|
||||
import { Anchor, Button, Paper, Text, Title, rem } from '@mantine/core';
|
||||
import { Anchor, Button, Paper, Text, Title } from '@mantine/core';
|
||||
|
||||
import { DocumentationLinkItem } from './DocumentationLinks';
|
||||
import * as classes from './GettingStartedCarousel.css';
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { Trans, t } from '@lingui/macro';
|
||||
import {
|
||||
Box,
|
||||
Code,
|
||||
Group,
|
||||
Image,
|
||||
Select,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import QR from 'qrcode';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../../states/SettingsState';
|
||||
import { CopyButton } from '../buttons/CopyButton';
|
||||
|
||||
type QRCodeProps = {
|
||||
ecl?: 'L' | 'M' | 'Q' | 'H';
|
||||
margin?: number;
|
||||
data?: string;
|
||||
};
|
||||
|
||||
export const QRCode = ({ data, ecl = 'Q', margin = 1 }: QRCodeProps) => {
|
||||
const [qrCode, setQRCode] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return setQRCode(undefined);
|
||||
|
||||
QR.toString(data, { errorCorrectionLevel: ecl, type: 'svg', margin }).then(
|
||||
(svg) => {
|
||||
setQRCode(`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`);
|
||||
}
|
||||
);
|
||||
}, [data, ecl]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{qrCode ? (
|
||||
<Image src={qrCode} alt="QR Code" />
|
||||
) : (
|
||||
<Skeleton height={500} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
type InvenTreeQRCodeProps = {
|
||||
model: ModelType;
|
||||
pk: number;
|
||||
showEclSelector?: boolean;
|
||||
} & Omit<QRCodeProps, 'data'>;
|
||||
|
||||
export const InvenTreeQRCode = ({
|
||||
showEclSelector = true,
|
||||
model,
|
||||
pk,
|
||||
ecl: eclProp = 'Q',
|
||||
...props
|
||||
}: InvenTreeQRCodeProps) => {
|
||||
const settings = useGlobalSettingsState();
|
||||
const [ecl, setEcl] = useState(eclProp);
|
||||
|
||||
useEffect(() => {
|
||||
if (eclProp) setEcl(eclProp);
|
||||
}, [eclProp]);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['qr-code', model, pk],
|
||||
queryFn: async () => {
|
||||
const res = await api.post(apiUrl(ApiEndpoints.generate_barcode), {
|
||||
model,
|
||||
pk
|
||||
});
|
||||
|
||||
return res.data?.barcode as string;
|
||||
}
|
||||
});
|
||||
|
||||
const eclOptions = useMemo(
|
||||
() => [
|
||||
{ value: 'L', label: t`Low (7%)` },
|
||||
{ value: 'M', label: t`Medium (15%)` },
|
||||
{ value: 'Q', label: t`Quartile (25%)` },
|
||||
{ value: 'H', label: t`High (30%)` }
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<QRCode data={data} ecl={ecl} {...props} />
|
||||
|
||||
{data && settings.getSetting('BARCODE_SHOW_TEXT', 'false') && (
|
||||
<Group
|
||||
justify={showEclSelector ? 'space-between' : 'center'}
|
||||
align="flex-start"
|
||||
px={16}
|
||||
>
|
||||
<Stack gap={4} pt={2}>
|
||||
<Text size="sm" fw={500}>
|
||||
<Trans>Barcode Data:</Trans>
|
||||
</Text>
|
||||
<Group>
|
||||
<Code>{data}</Code>
|
||||
<CopyButton value={data} />
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
{showEclSelector && (
|
||||
<Select
|
||||
allowDeselect={false}
|
||||
label={t`Select Error Correction Level`}
|
||||
value={ecl}
|
||||
onChange={(v) =>
|
||||
setEcl(v as Exclude<QRCodeProps['ecl'], undefined>)
|
||||
}
|
||||
data={eclOptions}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -35,7 +35,7 @@ export function QrCodeModal({
|
||||
key: 'camId',
|
||||
defaultValue: null
|
||||
});
|
||||
const [ScanningEnabled, setIsScanning] = useState<boolean>(false);
|
||||
const [scanningEnabled, setScanningEnabled] = useState<boolean>(false);
|
||||
const [wasAutoPaused, setWasAutoPaused] = useState<boolean>(false);
|
||||
const documentState = useDocumentVisibility();
|
||||
|
||||
@@ -48,7 +48,7 @@ export function QrCodeModal({
|
||||
|
||||
// Stop/star when leaving or reentering page
|
||||
useEffect(() => {
|
||||
if (ScanningEnabled && documentState === 'hidden') {
|
||||
if (scanningEnabled && documentState === 'hidden') {
|
||||
stopScanning();
|
||||
setWasAutoPaused(true);
|
||||
} else if (wasAutoPaused && documentState === 'visible') {
|
||||
@@ -128,12 +128,12 @@ export function QrCodeModal({
|
||||
icon: <IconX />
|
||||
});
|
||||
});
|
||||
setIsScanning(true);
|
||||
setScanningEnabled(true);
|
||||
}
|
||||
}
|
||||
|
||||
function stopScanning() {
|
||||
if (qrCodeScanner && ScanningEnabled) {
|
||||
if (qrCodeScanner && scanningEnabled) {
|
||||
qrCodeScanner.stop().catch((err: string) => {
|
||||
showNotification({
|
||||
title: t`Error while stopping`,
|
||||
@@ -142,7 +142,7 @@ export function QrCodeModal({
|
||||
icon: <IconX />
|
||||
});
|
||||
});
|
||||
setIsScanning(false);
|
||||
setScanningEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ export function QrCodeModal({
|
||||
<Group>
|
||||
<Text size="sm">{camId?.label}</Text>
|
||||
<Space style={{ flex: 1 }} />
|
||||
<Badge>{ScanningEnabled ? t`Scanning` : t`Not scanning`}</Badge>
|
||||
<Badge>{scanningEnabled ? t`Scanning` : t`Not scanning`}</Badge>
|
||||
</Group>
|
||||
<Container px={0} id="reader" w={'100%'} mih="300px" />
|
||||
{!camId ? (
|
||||
@@ -164,14 +164,14 @@ export function QrCodeModal({
|
||||
<Button
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => startScanning()}
|
||||
disabled={camId != undefined && ScanningEnabled}
|
||||
disabled={camId != undefined && scanningEnabled}
|
||||
>
|
||||
<Trans>Start scanning</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
style={{ flex: 1 }}
|
||||
onClick={() => stopScanning()}
|
||||
disabled={!ScanningEnabled}
|
||||
disabled={!scanningEnabled}
|
||||
>
|
||||
<Trans>Stop scanning</Trans>
|
||||
</Button>
|
||||
|
||||
@@ -91,7 +91,7 @@ export function ServerInfoModal({
|
||||
</OnlyStaff>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{server.worker_running != true && (
|
||||
{server?.worker_running == false && (
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Background Worker</Trans>
|
||||
@@ -103,7 +103,7 @@ export function ServerInfoModal({
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
{server.email_configured != true && (
|
||||
{server?.email_configured == false && (
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Trans>Email Settings</Trans>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { identifierString } from '../../functions/conversion';
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
|
||||
export type Breadcrumb = {
|
||||
icon?: React.ReactNode;
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
@@ -69,7 +70,10 @@ export function BreadcrumbList({
|
||||
navigateToLink(breadcrumb.url, navigate, event)
|
||||
}
|
||||
>
|
||||
<Text size="sm">{breadcrumb.name}</Text>
|
||||
<Group gap={4}>
|
||||
{breadcrumb.icon}
|
||||
<Text size="sm">{breadcrumb.name}</Text>
|
||||
</Group>
|
||||
</Anchor>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { ActionIcon, Container, Group, Indicator, Tabs } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconBell, IconSearch } from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
import { useMatch, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { api } from '../../App';
|
||||
@@ -47,6 +47,10 @@ export function Header() {
|
||||
queryKey: ['notification-count'],
|
||||
enabled: isLoggedIn(),
|
||||
queryFn: async () => {
|
||||
if (!isLoggedIn()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = {
|
||||
params: {
|
||||
@@ -60,14 +64,13 @@ export function Header() {
|
||||
return null;
|
||||
});
|
||||
setNotificationCount(response?.data?.count ?? 0);
|
||||
return response?.data;
|
||||
return response?.data ?? null;
|
||||
} catch (error) {
|
||||
return error;
|
||||
return null;
|
||||
}
|
||||
},
|
||||
refetchInterval: 30000,
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: false
|
||||
refetchOnMount: true
|
||||
});
|
||||
|
||||
// Sync Navigation Drawer state with zustand
|
||||
@@ -129,10 +132,35 @@ export function Header() {
|
||||
}
|
||||
|
||||
function NavTabs() {
|
||||
const user = useUserState();
|
||||
const navigate = useNavigate();
|
||||
const match = useMatch(':tabName/*');
|
||||
const tabValue = match?.params.tabName;
|
||||
|
||||
const tabs: ReactNode[] = useMemo(() => {
|
||||
let _tabs: ReactNode[] = [];
|
||||
|
||||
mainNavTabs.forEach((tab) => {
|
||||
if (tab.role && !user.hasViewRole(tab.role)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_tabs.push(
|
||||
<Tabs.Tab
|
||||
value={tab.name}
|
||||
key={tab.name}
|
||||
onClick={(event: any) =>
|
||||
navigateToLink(`/${tab.name}`, navigate, event)
|
||||
}
|
||||
>
|
||||
{tab.text}
|
||||
</Tabs.Tab>
|
||||
);
|
||||
});
|
||||
|
||||
return _tabs;
|
||||
}, [mainNavTabs, user]);
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue="home"
|
||||
@@ -143,19 +171,7 @@ function NavTabs() {
|
||||
}}
|
||||
value={tabValue}
|
||||
>
|
||||
<Tabs.List>
|
||||
{mainNavTabs.map((tab) => (
|
||||
<Tabs.Tab
|
||||
value={tab.name}
|
||||
key={tab.name}
|
||||
onClick={(event: any) =>
|
||||
navigateToLink(`/${tab.name}`, navigate, event)
|
||||
}
|
||||
>
|
||||
{tab.text}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
<Tabs.List>{tabs.map((tab) => tab)}</Tabs.List>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { LoadingOverlay } from '@mantine/core';
|
||||
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import ClientError from '../errors/ClientError';
|
||||
import ServerError from '../errors/ServerError';
|
||||
|
||||
export default function InstanceDetail({
|
||||
status,
|
||||
loading,
|
||||
children
|
||||
}: {
|
||||
status: number;
|
||||
loading: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const user = useUserState();
|
||||
|
||||
if (loading || !user.isLoggedIn()) {
|
||||
return <LoadingOverlay />;
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
return <ServerError status={status} />;
|
||||
}
|
||||
|
||||
if (status >= 400) {
|
||||
return <ClientError status={status} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconPoint,
|
||||
IconSitemap
|
||||
} from '@tabler/icons-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
@@ -28,6 +27,7 @@ import { ModelType } from '../../enums/ModelType';
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { ApiIcon } from '../items/ApiIcon';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
|
||||
/*
|
||||
@@ -100,7 +100,12 @@ export default function NavigationTree({
|
||||
let node = {
|
||||
...query.data[ii],
|
||||
children: [],
|
||||
label: query.data[ii].name,
|
||||
label: (
|
||||
<Group gap="xs">
|
||||
<ApiIcon name={query.data[ii].icon} />
|
||||
{query.data[ii].name}
|
||||
</Group>
|
||||
),
|
||||
value: query.data[ii].pk.toString(),
|
||||
selected: query.data[ii].pk === selectedId
|
||||
};
|
||||
@@ -157,9 +162,7 @@ export default function NavigationTree({
|
||||
) : (
|
||||
<IconChevronRight />
|
||||
)
|
||||
) : (
|
||||
<IconPoint />
|
||||
)}
|
||||
) : null}
|
||||
</ActionIcon>
|
||||
<Anchor
|
||||
onClick={(event: any) => follow(payload.node, event)}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Drawer,
|
||||
Group,
|
||||
Loader,
|
||||
LoadingOverlay,
|
||||
Space,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -21,6 +20,7 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../../App';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
import { StylishText } from '../items/StylishText';
|
||||
|
||||
/**
|
||||
@@ -33,10 +33,12 @@ export function NotificationDrawer({
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { isLoggedIn } = useUserState();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const notificationQuery = useQuery({
|
||||
enabled: opened,
|
||||
enabled: opened && isLoggedIn(),
|
||||
queryKey: ['notifications', opened],
|
||||
queryFn: async () =>
|
||||
api
|
||||
@@ -50,8 +52,7 @@ export function NotificationDrawer({
|
||||
.catch((error) => {
|
||||
return error;
|
||||
}),
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false
|
||||
refetchOnMount: false
|
||||
});
|
||||
|
||||
const hasNotifications: boolean = useMemo(() => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Breadcrumb, BreadcrumbList } from './BreadcrumbList';
|
||||
|
||||
interface PageDetailInterface {
|
||||
title?: string;
|
||||
icon?: ReactNode;
|
||||
subtitle?: string;
|
||||
imageUrl?: string;
|
||||
detail?: ReactNode;
|
||||
@@ -24,6 +25,7 @@ interface PageDetailInterface {
|
||||
*/
|
||||
export function PageDetail({
|
||||
title,
|
||||
icon,
|
||||
subtitle,
|
||||
detail,
|
||||
badges,
|
||||
@@ -50,9 +52,12 @@ export function PageDetail({
|
||||
<Stack gap="xs">
|
||||
{title && <StylishText size="lg">{title}</StylishText>}
|
||||
{subtitle && (
|
||||
<Text size="md" truncate>
|
||||
{subtitle}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{icon}
|
||||
<Text size="md" truncate>
|
||||
{subtitle}
|
||||
</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
@@ -119,6 +119,7 @@ function BasePanelGroup({
|
||||
label={panel.label}
|
||||
key={panel.name}
|
||||
disabled={expanded}
|
||||
position="right"
|
||||
>
|
||||
<Tabs.Tab
|
||||
p="xs"
|
||||
|
||||
@@ -33,6 +33,7 @@ import { api } from '../../App';
|
||||
import { ApiEndpoints } from '../../enums/ApiEndpoints';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { UserRoles } from '../../enums/Roles';
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { useUserSettingsState } from '../../states/SettingsState';
|
||||
import { useUserState } from '../../states/UserState';
|
||||
@@ -58,7 +59,7 @@ function QueryResultGroup({
|
||||
}: {
|
||||
query: SearchQuery;
|
||||
onRemove: (query: ModelType) => void;
|
||||
onResultClick: (query: ModelType, pk: number) => void;
|
||||
onResultClick: (query: ModelType, pk: number, event: any) => void;
|
||||
}) {
|
||||
if (query.results.count == 0) {
|
||||
return null;
|
||||
@@ -92,7 +93,9 @@ function QueryResultGroup({
|
||||
<Stack>
|
||||
{query.results.results.map((result: any) => (
|
||||
<Anchor
|
||||
onClick={() => onResultClick(query.model, result.pk)}
|
||||
onClick={(event: any) =>
|
||||
onResultClick(query.model, result.pk, event)
|
||||
}
|
||||
key={result.pk}
|
||||
>
|
||||
<RenderInstance instance={result} model={query.model} />
|
||||
@@ -272,8 +275,7 @@ export function SearchDrawer({
|
||||
// Search query manager
|
||||
const searchQuery = useQuery({
|
||||
queryKey: ['search', searchText, searchRegex, searchWhole],
|
||||
queryFn: performSearch,
|
||||
refetchOnWindowFocus: false
|
||||
queryFn: performSearch
|
||||
});
|
||||
|
||||
// A list of queries which return valid results
|
||||
@@ -316,11 +318,20 @@ export function SearchDrawer({
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Callback when one of the search results is clicked
|
||||
function onResultClick(query: ModelType, pk: number) {
|
||||
closeDrawer();
|
||||
function onResultClick(query: ModelType, pk: number, event: any) {
|
||||
const targetModel = ModelInformationDict[query];
|
||||
if (targetModel.url_detail == undefined) return;
|
||||
navigate(targetModel.url_detail.replace(':pk', pk.toString()));
|
||||
if (targetModel.url_detail == undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event?.ctrlKey || event?.shiftKey) {
|
||||
// Keep the drawer open in this condition
|
||||
} else {
|
||||
closeDrawer();
|
||||
}
|
||||
|
||||
let url = targetModel.url_detail.replace(':pk', pk.toString());
|
||||
navigateToLink(url, navigate, event);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -400,7 +411,9 @@ export function SearchDrawer({
|
||||
key={idx}
|
||||
query={query}
|
||||
onRemove={(query) => removeResults(query)}
|
||||
onResultClick={(query, pk) => onResultClick(query, pk)}
|
||||
onResultClick={(query, pk, event) =>
|
||||
onResultClick(query, pk, event)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
@@ -46,3 +46,9 @@ export function RenderBuildLine({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderBuildItem({
|
||||
instance
|
||||
}: Readonly<InstanceRenderInterface>): ReactNode {
|
||||
return <RenderInlineModel primary={instance.pk} />;
|
||||
}
|
||||
|
||||
@@ -14,3 +14,11 @@ export function RenderProjectCode({
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderImportSession({
|
||||
instance
|
||||
}: {
|
||||
instance: any;
|
||||
}): ReactNode {
|
||||
return instance && <RenderInlineModel primary={instance.data_file} />;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Alert, Anchor, Group, Space, Text } from '@mantine/core';
|
||||
import { Alert, Anchor, Group, Skeleton, Space, Text } from '@mantine/core';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
|
||||
import { api } from '../../App';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { navigateToLink } from '../../functions/navigation';
|
||||
import { apiUrl } from '../../states/ApiState';
|
||||
import { Thumbnail } from '../images/Thumbnail';
|
||||
import { RenderBuildLine, RenderBuildOrder } from './Build';
|
||||
import { RenderBuildItem, RenderBuildLine, RenderBuildOrder } from './Build';
|
||||
import {
|
||||
RenderAddress,
|
||||
RenderCompany,
|
||||
@@ -13,10 +16,12 @@ import {
|
||||
RenderManufacturerPart,
|
||||
RenderSupplierPart
|
||||
} from './Company';
|
||||
import { RenderProjectCode } from './Generic';
|
||||
import { RenderImportSession, RenderProjectCode } from './Generic';
|
||||
import { ModelInformationDict } from './ModelType';
|
||||
import {
|
||||
RenderPurchaseOrder,
|
||||
RenderReturnOrder,
|
||||
RenderReturnOrderLineItem,
|
||||
RenderSalesOrder,
|
||||
RenderSalesOrderShipment
|
||||
} from './Order';
|
||||
@@ -33,7 +38,7 @@ import {
|
||||
RenderStockLocation,
|
||||
RenderStockLocationType
|
||||
} from './Stock';
|
||||
import { RenderOwner, RenderUser } from './User';
|
||||
import { RenderGroup, RenderOwner, RenderUser } from './User';
|
||||
|
||||
type EnumDictionary<T extends string | symbol | number, U> = {
|
||||
[K in T]: U;
|
||||
@@ -43,6 +48,7 @@ export interface InstanceRenderInterface {
|
||||
instance: any;
|
||||
link?: boolean;
|
||||
navigate?: any;
|
||||
showSecondary?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -55,6 +61,7 @@ const RendererLookup: EnumDictionary<
|
||||
[ModelType.address]: RenderAddress,
|
||||
[ModelType.build]: RenderBuildOrder,
|
||||
[ModelType.buildline]: RenderBuildLine,
|
||||
[ModelType.builditem]: RenderBuildItem,
|
||||
[ModelType.company]: RenderCompany,
|
||||
[ModelType.contact]: RenderContact,
|
||||
[ModelType.manufacturerpart]: RenderManufacturerPart,
|
||||
@@ -65,8 +72,9 @@ const RendererLookup: EnumDictionary<
|
||||
[ModelType.parttesttemplate]: RenderPartTestTemplate,
|
||||
[ModelType.projectcode]: RenderProjectCode,
|
||||
[ModelType.purchaseorder]: RenderPurchaseOrder,
|
||||
[ModelType.purchaseorderline]: RenderPurchaseOrder,
|
||||
[ModelType.purchaseorderlineitem]: RenderPurchaseOrder,
|
||||
[ModelType.returnorder]: RenderReturnOrder,
|
||||
[ModelType.returnorderlineitem]: RenderReturnOrderLineItem,
|
||||
[ModelType.salesorder]: RenderSalesOrder,
|
||||
[ModelType.salesordershipment]: RenderSalesOrderShipment,
|
||||
[ModelType.stocklocation]: RenderStockLocation,
|
||||
@@ -75,6 +83,8 @@ const RendererLookup: EnumDictionary<
|
||||
[ModelType.stockhistory]: RenderStockItem,
|
||||
[ModelType.supplierpart]: RenderSupplierPart,
|
||||
[ModelType.user]: RenderUser,
|
||||
[ModelType.group]: RenderGroup,
|
||||
[ModelType.importsession]: RenderImportSession,
|
||||
[ModelType.reporttemplate]: RenderReportTemplate,
|
||||
[ModelType.labeltemplate]: RenderLabelTemplate,
|
||||
[ModelType.pluginconfig]: RenderPlugin
|
||||
@@ -103,25 +113,65 @@ export function RenderInstance(props: RenderInstanceProps): ReactNode {
|
||||
return <RenderComponent {...props} />;
|
||||
}
|
||||
|
||||
export function RenderRemoteInstance({
|
||||
model,
|
||||
pk
|
||||
}: {
|
||||
model: ModelType;
|
||||
pk: number;
|
||||
}): ReactNode {
|
||||
const { data, isLoading, isFetching } = useQuery({
|
||||
queryKey: ['model', model, pk],
|
||||
queryFn: async () => {
|
||||
const url = apiUrl(ModelInformationDict[model].api_endpoint, pk);
|
||||
|
||||
return api
|
||||
.get(url)
|
||||
.then((response) => response.data)
|
||||
.catch(() => null);
|
||||
}
|
||||
});
|
||||
|
||||
if (isLoading || isFetching) {
|
||||
return <Skeleton />;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Text>
|
||||
{model}: {pk}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return <RenderInstance model={model} instance={data} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function for rendering an inline model in a consistent style
|
||||
*/
|
||||
export function RenderInlineModel({
|
||||
primary,
|
||||
secondary,
|
||||
prefix,
|
||||
suffix,
|
||||
image,
|
||||
labels,
|
||||
url,
|
||||
navigate
|
||||
navigate,
|
||||
showSecondary = true,
|
||||
tooltip
|
||||
}: {
|
||||
primary: string;
|
||||
secondary?: string;
|
||||
showSecondary?: boolean;
|
||||
prefix?: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
image?: string;
|
||||
labels?: string[];
|
||||
url?: string;
|
||||
navigate?: any;
|
||||
tooltip?: string;
|
||||
}): ReactNode {
|
||||
// TODO: Handle labels
|
||||
|
||||
@@ -135,9 +185,10 @@ export function RenderInlineModel({
|
||||
);
|
||||
|
||||
return (
|
||||
<Group gap="xs" justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" justify="space-between" wrap="nowrap" title={tooltip}>
|
||||
<Group gap="xs" justify="left" wrap="nowrap">
|
||||
{image && Thumbnail({ src: image, size: 18 })}
|
||||
{prefix}
|
||||
{image && <Thumbnail src={image} size={18} />}
|
||||
{url ? (
|
||||
<Anchor href={url} onClick={(event: any) => onClick(event)}>
|
||||
<Text size="sm">{primary}</Text>
|
||||
@@ -145,7 +196,7 @@ export function RenderInlineModel({
|
||||
) : (
|
||||
<Text size="sm">{primary}</Text>
|
||||
)}
|
||||
{secondary && <Text size="xs">{secondary}</Text>}
|
||||
{showSecondary && secondary && <Text size="xs">{secondary}</Text>}
|
||||
</Group>
|
||||
{suffix && (
|
||||
<>
|
||||
|
||||
@@ -113,6 +113,11 @@ export const ModelInformationDict: ModelDict = {
|
||||
cui_detail: '/build/line/:pk/',
|
||||
api_endpoint: ApiEndpoints.build_line_list
|
||||
},
|
||||
builditem: {
|
||||
label: t`Build Item`,
|
||||
label_multiple: t`Build Items`,
|
||||
api_endpoint: ApiEndpoints.build_item_list
|
||||
},
|
||||
company: {
|
||||
label: t`Company`,
|
||||
label_multiple: t`Companies`,
|
||||
@@ -138,7 +143,7 @@ export const ModelInformationDict: ModelDict = {
|
||||
api_endpoint: ApiEndpoints.purchase_order_list,
|
||||
admin_url: '/order/purchaseorder/'
|
||||
},
|
||||
purchaseorderline: {
|
||||
purchaseorderlineitem: {
|
||||
label: t`Purchase Order Line`,
|
||||
label_multiple: t`Purchase Order Lines`,
|
||||
api_endpoint: ApiEndpoints.purchase_order_line_list
|
||||
@@ -168,6 +173,11 @@ export const ModelInformationDict: ModelDict = {
|
||||
api_endpoint: ApiEndpoints.return_order_list,
|
||||
admin_url: '/order/returnorder/'
|
||||
},
|
||||
returnorderlineitem: {
|
||||
label: t`Return Order Line Item`,
|
||||
label_multiple: t`Return Order Line Items`,
|
||||
api_endpoint: ApiEndpoints.return_order_line_list
|
||||
},
|
||||
address: {
|
||||
label: t`Address`,
|
||||
label_multiple: t`Addresses`,
|
||||
@@ -196,6 +206,21 @@ export const ModelInformationDict: ModelDict = {
|
||||
url_detail: '/user/:pk/',
|
||||
api_endpoint: ApiEndpoints.user_list
|
||||
},
|
||||
group: {
|
||||
label: t`Group`,
|
||||
label_multiple: t`Groups`,
|
||||
url_overview: '/user/group',
|
||||
url_detail: '/user/group-:pk',
|
||||
api_endpoint: ApiEndpoints.group_list,
|
||||
admin_url: '/auth/group/'
|
||||
},
|
||||
importsession: {
|
||||
label: t`Import Session`,
|
||||
label_multiple: t`Import Sessions`,
|
||||
url_overview: '/import',
|
||||
url_detail: '/import/:pk/',
|
||||
api_endpoint: ApiEndpoints.import_session_list
|
||||
},
|
||||
labeltemplate: {
|
||||
label: t`Label Template`,
|
||||
label_multiple: t`Label Templates`,
|
||||
|
||||
@@ -62,6 +62,23 @@ export function RenderReturnOrder(
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderReturnOrderLineItem(
|
||||
props: Readonly<InstanceRenderInterface>
|
||||
): ReactNode {
|
||||
const { instance } = props;
|
||||
|
||||
return (
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
primary={instance.reference}
|
||||
suffix={StatusRenderer({
|
||||
status: instance.outcome,
|
||||
type: ModelType.returnorderlineitem
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline rendering of a single SalesOrder instance
|
||||
*/
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Badge } from '@mantine/core';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { ApiIcon } from '../items/ApiIcon';
|
||||
import { InstanceRenderInterface, RenderInlineModel } from './Instance';
|
||||
|
||||
/**
|
||||
@@ -12,14 +14,35 @@ export function RenderPart(
|
||||
props: Readonly<InstanceRenderInterface>
|
||||
): ReactNode {
|
||||
const { instance } = props;
|
||||
const stock = t`Stock` + `: ${instance.in_stock}`;
|
||||
|
||||
let badgeText = '';
|
||||
let badgeColor = '';
|
||||
|
||||
let stock = instance.total_in_stock;
|
||||
|
||||
if (instance.active == false) {
|
||||
badgeColor = 'red';
|
||||
badgeText = t`Inactive`;
|
||||
} else if (stock <= 0) {
|
||||
badgeColor = 'orange';
|
||||
badgeText = t`No stock`;
|
||||
} else {
|
||||
badgeText = t`Stock` + `: ${stock}`;
|
||||
badgeColor = instance.minimum_stock > stock ? 'yellow' : 'green';
|
||||
}
|
||||
|
||||
const badge = (
|
||||
<Badge size="xs" color={badgeColor}>
|
||||
{badgeText}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return (
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
primary={instance.name}
|
||||
primary={instance.full_name ?? instance.name}
|
||||
secondary={instance.description}
|
||||
suffix={stock}
|
||||
suffix={badge}
|
||||
image={instance.thumnbnail || instance.image}
|
||||
url={props.link ? getDetailUrl(ModelType.part, instance.pk) : undefined}
|
||||
/>
|
||||
@@ -33,12 +56,18 @@ export function RenderPartCategory(
|
||||
props: Readonly<InstanceRenderInterface>
|
||||
): ReactNode {
|
||||
const { instance } = props;
|
||||
const lvl = '-'.repeat(instance.level || 0);
|
||||
|
||||
return (
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
primary={`${lvl} ${instance.name}`}
|
||||
tooltip={instance.pathstring}
|
||||
prefix={
|
||||
<>
|
||||
<div style={{ width: 10 * (instance.level || 0) }}></div>
|
||||
{instance.icon && <ApiIcon name={instance.icon} />}
|
||||
</>
|
||||
}
|
||||
primary={instance.name}
|
||||
secondary={instance.description}
|
||||
url={
|
||||
props.link
|
||||
|
||||
@@ -2,11 +2,13 @@ import { Badge, Center, MantineSize } from '@mantine/core';
|
||||
|
||||
import { colorMap } from '../../defaults/backendMappings';
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { resolveItem } from '../../functions/conversion';
|
||||
import { useGlobalStatusState } from '../../states/StatusState';
|
||||
|
||||
interface StatusCodeInterface {
|
||||
key: string;
|
||||
label: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
@@ -41,7 +43,9 @@ function renderStatusLabel(
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
console.error(`renderStatusLabel could not find match for code ${key}`);
|
||||
console.error(
|
||||
`ERR: renderStatusLabel could not find match for code ${key}`
|
||||
);
|
||||
}
|
||||
|
||||
// Fallbacks
|
||||
@@ -59,6 +63,49 @@ function renderStatusLabel(
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function getStatusCodes(type: ModelType | string) {
|
||||
const statusCodeList = useGlobalStatusState.getState().status;
|
||||
|
||||
if (statusCodeList === undefined) {
|
||||
console.log('StatusRenderer: statusCodeList is undefined');
|
||||
return null;
|
||||
}
|
||||
|
||||
const statusCodes = statusCodeList[type];
|
||||
|
||||
if (statusCodes === undefined) {
|
||||
console.log('StatusRenderer: statusCodes is undefined');
|
||||
return null;
|
||||
}
|
||||
|
||||
return statusCodes;
|
||||
}
|
||||
|
||||
/*
|
||||
* Return the name of a status code, based on the key
|
||||
*/
|
||||
export function getStatusCodeName(
|
||||
type: ModelType | string,
|
||||
key: string | number
|
||||
) {
|
||||
const statusCodes = getStatusCodes(type);
|
||||
|
||||
if (!statusCodes) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let name in statusCodes) {
|
||||
let entry = statusCodes[name];
|
||||
|
||||
if (entry.key == key) {
|
||||
return entry.name;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Render the status for a object.
|
||||
* Uses the values specified in "status_codes.py"
|
||||
@@ -72,14 +119,9 @@ export const StatusRenderer = ({
|
||||
type: ModelType | string;
|
||||
options?: RenderStatusLabelOptionsInterface;
|
||||
}) => {
|
||||
const statusCodeList = useGlobalStatusState.getState().status;
|
||||
const statusCodes = getStatusCodes(type);
|
||||
|
||||
if (status === undefined || statusCodeList === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const statusCodes = statusCodeList[type];
|
||||
if (statusCodes === undefined) {
|
||||
if (statusCodes === undefined || statusCodes === null) {
|
||||
console.warn('StatusRenderer: statusCodes is undefined');
|
||||
return null;
|
||||
}
|
||||
@@ -91,10 +133,16 @@ export const StatusRenderer = ({
|
||||
* Render the status badge in a table
|
||||
*/
|
||||
export function TableStatusRenderer(
|
||||
type: ModelType
|
||||
type: ModelType,
|
||||
accessor?: string
|
||||
): ((record: any) => any) | undefined {
|
||||
return (record: any) =>
|
||||
record.status && (
|
||||
<Center>{StatusRenderer({ status: record.status, type: type })}</Center>
|
||||
return (record: any) => {
|
||||
const status = resolveItem(record, accessor ?? 'status');
|
||||
|
||||
return (
|
||||
status && (
|
||||
<Center>{StatusRenderer({ status: status, type: type })}</Center>
|
||||
)
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ReactNode } from 'react';
|
||||
|
||||
import { ModelType } from '../../enums/ModelType';
|
||||
import { getDetailUrl } from '../../functions/urls';
|
||||
import { ApiIcon } from '../items/ApiIcon';
|
||||
import { InstanceRenderInterface, RenderInlineModel } from './Instance';
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,13 @@ export function RenderStockLocation(
|
||||
return (
|
||||
<RenderInlineModel
|
||||
{...props}
|
||||
tooltip={instance.pathstring}
|
||||
prefix={
|
||||
<>
|
||||
<div style={{ width: 10 * (instance.level || 0) }}></div>
|
||||
{instance.icon && <ApiIcon name={instance.icon} />}
|
||||
</>
|
||||
}
|
||||
primary={instance.name}
|
||||
secondary={instance.description}
|
||||
url={
|
||||
@@ -36,7 +44,7 @@ export function RenderStockLocationType({
|
||||
return (
|
||||
<RenderInlineModel
|
||||
primary={instance.name}
|
||||
// TODO: render location icon here too (ref: #7237)
|
||||
prefix={instance.icon && <ApiIcon name={instance.icon} />}
|
||||
secondary={instance.description + ` (${instance.location_count})`}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,13 @@ export function RenderOwner({
|
||||
instance && (
|
||||
<RenderInlineModel
|
||||
primary={instance.name}
|
||||
suffix={instance.label == 'group' ? <IconUsersGroup /> : <IconUser />}
|
||||
suffix={
|
||||
instance.label == 'group' ? (
|
||||
<IconUsersGroup size={16} />
|
||||
) : (
|
||||
<IconUser size={16} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
)
|
||||
);
|
||||
@@ -28,3 +34,9 @@ export function RenderUser({
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderGroup({
|
||||
instance
|
||||
}: Readonly<InstanceRenderInterface>): ReactNode {
|
||||
return instance && <RenderInlineModel primary={instance.name} />;
|
||||
}
|
||||
|
||||
@@ -49,12 +49,12 @@ export function SettingList({
|
||||
|
||||
// Determine the field type of the setting
|
||||
const fieldType = useMemo(() => {
|
||||
if (setting?.type != undefined) {
|
||||
return setting.type;
|
||||
if (setting?.choices?.length) {
|
||||
return 'choice';
|
||||
}
|
||||
|
||||
if (setting?.choices != undefined && setting.choices.length > 0) {
|
||||
return 'choice';
|
||||
if (setting?.type != undefined) {
|
||||
return setting.type;
|
||||
}
|
||||
|
||||
return 'string';
|
||||
@@ -136,6 +136,10 @@ export function SettingList({
|
||||
(s: any) => s.key === key
|
||||
);
|
||||
|
||||
if (settingsState?.settings && !setting) {
|
||||
console.error(`Setting ${key} not found`);
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment key={key}>
|
||||
{setting ? (
|
||||
|
||||
@@ -17,6 +17,7 @@ export const defaultLocale = 'en';
|
||||
*/
|
||||
export const getSupportedLanguages = (): Record<string, string> => {
|
||||
return {
|
||||
ar: t`Arabic`,
|
||||
bg: t`Bulgarian`,
|
||||
cs: t`Czech`,
|
||||
da: t`Danish`,
|
||||
@@ -25,6 +26,7 @@ export const getSupportedLanguages = (): Record<string, string> => {
|
||||
en: t`English`,
|
||||
es: t`Spanish`,
|
||||
'es-mx': t`Spanish (Mexican)`,
|
||||
et: t`Estonian`,
|
||||
fa: t`Farsi / Persian`,
|
||||
fi: t`Finnish`,
|
||||
fr: t`French`,
|
||||
@@ -94,6 +96,12 @@ export function LanguageContext({ children }: { children: JSX.Element }) {
|
||||
locales.push('en-us');
|
||||
}
|
||||
|
||||
let new_locales = locales.join(', ');
|
||||
|
||||
if (new_locales == api.defaults.headers.common['Accept-Language']) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update default Accept-Language headers
|
||||
api.defaults.headers.common['Accept-Language'] = locales.join(', ');
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@ import { ModelType } from '../enums/ModelType';
|
||||
export const statusCodeList: Record<string, ModelType> = {
|
||||
BuildStatus: ModelType.build,
|
||||
PurchaseOrderStatus: ModelType.purchaseorder,
|
||||
ReturnOrderLineStatus: ModelType.purchaseorderline,
|
||||
ReturnOrderStatus: ModelType.returnorder,
|
||||
ReturnOrderLineStatus: ModelType.returnorderlineitem,
|
||||
SalesOrderStatus: ModelType.salesorder,
|
||||
StockHistoryCode: ModelType.stockhistory,
|
||||
StockStatus: ModelType.stockitem
|
||||
StockStatus: ModelType.stockitem,
|
||||
DataImportStatusCode: ModelType.importsession
|
||||
};
|
||||
|
||||
/*
|
||||
@@ -25,5 +26,7 @@ export const colorMap: { [key: string]: string } = {
|
||||
success: 'green',
|
||||
info: 'cyan',
|
||||
danger: 'red',
|
||||
primary: 'blue',
|
||||
secondary: 'gray',
|
||||
default: 'gray'
|
||||
};
|
||||
|
||||
@@ -117,7 +117,23 @@ export function formatPriceRange(
|
||||
)}`;
|
||||
}
|
||||
|
||||
interface RenderDateOptionsInterface {
|
||||
/*
|
||||
* Format a file size (in bytes) into a human-readable format
|
||||
*/
|
||||
export function formatFileSize(size: number) {
|
||||
const suffixes: string[] = ['B', 'KB', 'MB', 'GB'];
|
||||
|
||||
let idx = 0;
|
||||
|
||||
while (size > 1024 && idx < suffixes.length) {
|
||||
size /= 1024;
|
||||
idx++;
|
||||
}
|
||||
|
||||
return `${size.toFixed(2)} ${suffixes[idx]}`;
|
||||
}
|
||||
|
||||
interface FormatDateOptionsInterface {
|
||||
showTime?: boolean;
|
||||
showSeconds?: boolean;
|
||||
}
|
||||
@@ -128,9 +144,9 @@ interface RenderDateOptionsInterface {
|
||||
* The provided "date" variable is a string, nominally ISO format e.g. 2022-02-22
|
||||
* The user-configured setting DATE_DISPLAY_FORMAT determines how the date should be displayed.
|
||||
*/
|
||||
export function renderDate(
|
||||
export function formatDate(
|
||||
date: string,
|
||||
options: RenderDateOptionsInterface = {}
|
||||
options: FormatDateOptionsInterface = {}
|
||||
) {
|
||||
if (!date) {
|
||||
return '-';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { openContextModal } from '@mantine/modals';
|
||||
|
||||
import { DocumentationLinkItem } from '../components/items/DocumentationLinks';
|
||||
import { StylishText } from '../components/items/StylishText';
|
||||
import { UserRoles } from '../enums/Roles';
|
||||
import { IS_DEV_OR_DEMO } from '../main';
|
||||
|
||||
export const footerLinks = [
|
||||
@@ -25,12 +26,17 @@ export const footerLinks = [
|
||||
export const navTabs = [
|
||||
{ text: <Trans>Home</Trans>, name: 'home' },
|
||||
{ text: <Trans>Dashboard</Trans>, name: 'dashboard' },
|
||||
{ text: <Trans>Parts</Trans>, name: 'part' },
|
||||
{ text: <Trans>Stock</Trans>, name: 'stock' },
|
||||
{ text: <Trans>Build</Trans>, name: 'build' },
|
||||
{ text: <Trans>Purchasing</Trans>, name: 'purchasing' },
|
||||
{ text: <Trans>Sales</Trans>, name: 'sales' }
|
||||
{ text: <Trans>Parts</Trans>, name: 'part', role: UserRoles.part },
|
||||
{ text: <Trans>Stock</Trans>, name: 'stock', role: UserRoles.stock },
|
||||
{ text: <Trans>Build</Trans>, name: 'build', role: UserRoles.build },
|
||||
{
|
||||
text: <Trans>Purchasing</Trans>,
|
||||
name: 'purchasing',
|
||||
role: UserRoles.purchase_order
|
||||
},
|
||||
{ text: <Trans>Sales</Trans>, name: 'sales', role: UserRoles.sales_order }
|
||||
];
|
||||
|
||||
if (IS_DEV_OR_DEMO) {
|
||||
navTabs.push({ text: <Trans>Playground</Trans>, name: 'playground' });
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ export enum ApiEndpoints {
|
||||
user_me = 'user/me/',
|
||||
user_roles = 'user/roles/',
|
||||
user_token = 'user/token/',
|
||||
user_tokens = 'user/tokens/',
|
||||
user_simple_login = 'email/generate/',
|
||||
user_reset = 'auth/password/reset/',
|
||||
user_reset_set = 'auth/password/reset/confirm/',
|
||||
@@ -38,6 +39,7 @@ export enum ApiEndpoints {
|
||||
settings_global_list = 'settings/global/',
|
||||
settings_user_list = 'settings/user/',
|
||||
barcode = 'barcode/',
|
||||
generate_barcode = 'barcode/generate/',
|
||||
news = 'news/',
|
||||
global_status = 'generic/status/',
|
||||
version = 'version/',
|
||||
@@ -45,6 +47,14 @@ export enum ApiEndpoints {
|
||||
sso_providers = 'auth/providers/',
|
||||
group_list = 'user/group/',
|
||||
owner_list = 'user/owner/',
|
||||
icons = 'icons/',
|
||||
|
||||
// Data import endpoints
|
||||
import_session_list = 'importer/session/',
|
||||
import_session_accept_fields = 'importer/session/:id/accept_fields/',
|
||||
import_session_accept_rows = 'importer/session/:id/accept_rows/',
|
||||
import_session_column_mapping_list = 'importer/column-mapping/',
|
||||
import_session_row_list = 'importer/row/',
|
||||
|
||||
// Notification endpoints
|
||||
notifications_list = 'notifications/',
|
||||
@@ -52,15 +62,20 @@ export enum ApiEndpoints {
|
||||
|
||||
// Build API endpoints
|
||||
build_order_list = 'build/',
|
||||
build_order_issue = 'build/:id/issue/',
|
||||
build_order_cancel = 'build/:id/cancel/',
|
||||
build_output_create = 'build/:id/create-output/',
|
||||
build_order_hold = 'build/:id/hold/',
|
||||
build_order_complete = 'build/:id/finish/',
|
||||
build_output_complete = 'build/:id/complete/',
|
||||
build_output_create = 'build/:id/create-output/',
|
||||
build_output_scrap = 'build/:id/scrap-outputs/',
|
||||
build_output_delete = 'build/:id/delete-outputs/',
|
||||
build_order_attachment_list = 'build/attachment/',
|
||||
build_line_list = 'build/line/',
|
||||
build_item_list = 'build/item/',
|
||||
|
||||
bom_list = 'bom/',
|
||||
bom_item_validate = 'bom/:id/validate/',
|
||||
bom_validate = 'part/:id/bom-validate/',
|
||||
|
||||
// Part API endpoints
|
||||
part_list = 'part/',
|
||||
@@ -76,18 +91,15 @@ export enum ApiEndpoints {
|
||||
category_tree = 'part/category/tree/',
|
||||
category_parameter_list = 'part/category/parameters/',
|
||||
related_part_list = 'part/related/',
|
||||
part_attachment_list = 'part/attachment/',
|
||||
part_test_template_list = 'part/test-template/',
|
||||
|
||||
// Company API endpoints
|
||||
company_list = 'company/',
|
||||
contact_list = 'company/contact/',
|
||||
address_list = 'company/address/',
|
||||
company_attachment_list = 'company/attachment/',
|
||||
supplier_part_list = 'company/part/',
|
||||
supplier_part_pricing_list = 'company/price-break/',
|
||||
manufacturer_part_list = 'company/part/manufacturer/',
|
||||
manufacturer_part_attachment_list = 'company/part/manufacturer/attachment/',
|
||||
manufacturer_part_parameter_list = 'company/part/manufacturer/parameter/',
|
||||
|
||||
// Stock API endpoints
|
||||
@@ -96,7 +108,6 @@ export enum ApiEndpoints {
|
||||
stock_location_list = 'stock/location/',
|
||||
stock_location_type_list = 'stock/location-type/',
|
||||
stock_location_tree = 'stock/location/tree/',
|
||||
stock_attachment_list = 'stock/attachment/',
|
||||
stock_test_result_list = 'stock/test/',
|
||||
stock_transfer = 'stock/transfer/',
|
||||
stock_remove = 'stock/remove/',
|
||||
@@ -107,6 +118,8 @@ export enum ApiEndpoints {
|
||||
stock_assign = 'stock/assign/',
|
||||
stock_status = 'stock/status/',
|
||||
stock_install = 'stock/:id/install',
|
||||
build_test_statistics = 'test-statistics/by-build/:id',
|
||||
part_test_statistics = 'test-statistics/by-part/:id',
|
||||
|
||||
// Generator API endpoints
|
||||
generate_batch_code = 'generate/batch-code/',
|
||||
@@ -114,17 +127,29 @@ export enum ApiEndpoints {
|
||||
|
||||
// Order API endpoints
|
||||
purchase_order_list = 'order/po/',
|
||||
purchase_order_issue = 'order/po/:id/issue/',
|
||||
purchase_order_hold = 'order/po/:id/hold/',
|
||||
purchase_order_cancel = 'order/po/:id/cancel/',
|
||||
purchase_order_complete = 'order/po/:id/complete/',
|
||||
purchase_order_line_list = 'order/po-line/',
|
||||
purchase_order_attachment_list = 'order/po/attachment/',
|
||||
purchase_order_receive = 'order/po/:id/receive/',
|
||||
|
||||
sales_order_list = 'order/so/',
|
||||
sales_order_issue = 'order/so/:id/issue/',
|
||||
sales_order_hold = 'order/so/:id/hold/',
|
||||
sales_order_cancel = 'order/so/:id/cancel/',
|
||||
sales_order_ship = 'order/so/:id/ship/',
|
||||
sales_order_complete = 'order/so/:id/complete/',
|
||||
sales_order_line_list = 'order/so-line/',
|
||||
sales_order_attachment_list = 'order/so/attachment/',
|
||||
sales_order_allocation_list = 'order/so-allocation/',
|
||||
sales_order_shipment_list = 'order/so/shipment/',
|
||||
|
||||
return_order_list = 'order/ro/',
|
||||
return_order_attachment_list = 'order/ro/attachment/',
|
||||
return_order_issue = 'order/ro/:id/issue/',
|
||||
return_order_hold = 'order/ro/:id/hold/',
|
||||
return_order_cancel = 'order/ro/:id/cancel/',
|
||||
return_order_complete = 'order/ro/:id/complete/',
|
||||
return_order_line_list = 'order/ro-line/',
|
||||
|
||||
// Template API endpoints
|
||||
label_list = 'label/template/',
|
||||
@@ -156,6 +181,7 @@ export enum ApiEndpoints {
|
||||
machine_setting_detail = 'machine/:machine/settings/:config_type/',
|
||||
|
||||
// Miscellaneous API endpoints
|
||||
attachment_list = 'attachment/',
|
||||
error_report_list = 'error-report/',
|
||||
project_code_list = 'project-code/',
|
||||
custom_unit_list = 'units/',
|
||||
|
||||
@@ -15,16 +15,20 @@ export enum ModelType {
|
||||
stockhistory = 'stockhistory',
|
||||
build = 'build',
|
||||
buildline = 'buildline',
|
||||
builditem = 'builditem',
|
||||
company = 'company',
|
||||
purchaseorder = 'purchaseorder',
|
||||
purchaseorderline = 'purchaseorderline',
|
||||
purchaseorderlineitem = 'purchaseorderlineitem',
|
||||
salesorder = 'salesorder',
|
||||
salesordershipment = 'salesordershipment',
|
||||
returnorder = 'returnorder',
|
||||
returnorderlineitem = 'returnorderlineitem',
|
||||
importsession = 'importsession',
|
||||
address = 'address',
|
||||
contact = 'contact',
|
||||
owner = 'owner',
|
||||
user = 'user',
|
||||
group = 'group',
|
||||
reporttemplate = 'reporttemplate',
|
||||
labeltemplate = 'labeltemplate',
|
||||
pluginconfig = 'pluginconfig'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { ActionIcon, Alert, Stack, Text } from '@mantine/core';
|
||||
import { Alert, Stack, Text } from '@mantine/core';
|
||||
import {
|
||||
IconCalendar,
|
||||
IconLink,
|
||||
@@ -21,6 +21,7 @@ import { InvenTreeIcon } from '../functions/icons';
|
||||
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||
import { useBatchCodeGenerator } from '../hooks/UseGenerator';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../states/SettingsState';
|
||||
import { PartColumn, StatusColumn } from '../tables/ColumnRenderers';
|
||||
|
||||
/**
|
||||
@@ -43,6 +44,8 @@ export function useBuildOrderFields({
|
||||
}
|
||||
});
|
||||
|
||||
const globalSettings = useGlobalSettingsState();
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
reference: {},
|
||||
@@ -50,7 +53,13 @@ export function useBuildOrderFields({
|
||||
disabled: !create,
|
||||
filters: {
|
||||
assembly: true,
|
||||
virtual: false
|
||||
virtual: false,
|
||||
active: globalSettings.isSet('BUILDORDER_REQUIRE_ACTIVE_PART')
|
||||
? true
|
||||
: undefined,
|
||||
locked: globalSettings.isSet('BUILDORDER_REQUIRE_LOCKED_PART')
|
||||
? true
|
||||
: undefined
|
||||
},
|
||||
onValueChange(value: any, record?: any) {
|
||||
// Adjust the destination location for the build order
|
||||
@@ -98,7 +107,10 @@ export function useBuildOrderFields({
|
||||
icon: <IconLink />
|
||||
},
|
||||
issued_by: {
|
||||
icon: <IconUser />
|
||||
icon: <IconUser />,
|
||||
filters: {
|
||||
is_active: true
|
||||
}
|
||||
},
|
||||
responsible: {
|
||||
icon: <IconUsersGroup />,
|
||||
@@ -107,7 +119,7 @@ export function useBuildOrderFields({
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [create, destination, batchCode]);
|
||||
}, [create, destination, batchCode, globalSettings]);
|
||||
}
|
||||
|
||||
export function useBuildOrderOutputFields({
|
||||
|
||||
@@ -41,7 +41,8 @@ export function useSupplierPartFields() {
|
||||
},
|
||||
supplier: {
|
||||
filters: {
|
||||
active: true
|
||||
active: true,
|
||||
is_supplier: true
|
||||
}
|
||||
},
|
||||
SKU: {
|
||||
@@ -69,7 +70,12 @@ export function useManufacturerPartFields() {
|
||||
return useMemo(() => {
|
||||
const fields: ApiFormFieldSet = {
|
||||
part: {},
|
||||
manufacturer: {},
|
||||
manufacturer: {
|
||||
filters: {
|
||||
active: true,
|
||||
is_manufacturer: true
|
||||
}
|
||||
},
|
||||
MPN: {},
|
||||
description: {},
|
||||
link: {}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||
|
||||
export function dataImporterSessionFields(): ApiFormFieldSet {
|
||||
return {
|
||||
data_file: {},
|
||||
model_type: {},
|
||||
field_defaults: {
|
||||
hidden: true,
|
||||
value: {}
|
||||
},
|
||||
field_overrides: {
|
||||
hidden: true,
|
||||
value: {}
|
||||
},
|
||||
field_filters: {
|
||||
hidden: true,
|
||||
value: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { IconPackages } from '@tabler/icons-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
|
||||
import { useGlobalSettingsState } from '../states/SettingsState';
|
||||
|
||||
/**
|
||||
* Construct a set of fields for creating / editing a Part instance
|
||||
@@ -21,9 +22,19 @@ export function usePartFields({
|
||||
},
|
||||
name: {},
|
||||
IPN: {},
|
||||
revision: {},
|
||||
description: {},
|
||||
variant_of: {},
|
||||
revision: {},
|
||||
revision_of: {
|
||||
filters: {
|
||||
is_revision: false,
|
||||
is_template: false
|
||||
}
|
||||
},
|
||||
variant_of: {
|
||||
filters: {
|
||||
is_template: true
|
||||
}
|
||||
},
|
||||
keywords: {},
|
||||
units: {},
|
||||
link: {},
|
||||
@@ -46,6 +57,7 @@ export function usePartFields({
|
||||
purchaseable: {},
|
||||
salable: {},
|
||||
virtual: {},
|
||||
locked: {},
|
||||
active: {}
|
||||
};
|
||||
|
||||
@@ -56,7 +68,9 @@ export function usePartFields({
|
||||
fields.initial_stock = {
|
||||
icon: <IconPackages />,
|
||||
children: {
|
||||
quantity: {},
|
||||
quantity: {
|
||||
value: 0
|
||||
},
|
||||
location: {}
|
||||
}
|
||||
};
|
||||
@@ -79,13 +93,22 @@ export function usePartFields({
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: pop 'expiry' field if expiry not enabled
|
||||
delete fields['default_expiry'];
|
||||
const settings = useGlobalSettingsState.getState();
|
||||
|
||||
// TODO: pop 'revision' field if PART_ENABLE_REVISION is False
|
||||
delete fields['revision'];
|
||||
if (settings.isSet('PART_REVISION_ASSEMBLY_ONLY')) {
|
||||
fields.revision_of.filters['assembly'] = true;
|
||||
}
|
||||
|
||||
// TODO: handle part duplications
|
||||
// Pop 'revision' field if PART_ENABLE_REVISION is False
|
||||
if (!settings.isSet('PART_ENABLE_REVISION')) {
|
||||
delete fields['revision'];
|
||||
delete fields['revision_of'];
|
||||
}
|
||||
|
||||
// Pop 'expiry' field if expiry not enabled
|
||||
if (!settings.isSet('STOCK_ENABLE_EXPIRY')) {
|
||||
delete fields['default_expiry'];
|
||||
}
|
||||
|
||||
return fields;
|
||||
}, [create]);
|
||||
@@ -94,7 +117,7 @@ export function usePartFields({
|
||||
/**
|
||||
* Construct a set of fields for creating / editing a PartCategory instance
|
||||
*/
|
||||
export function partCategoryFields({}: {}): ApiFormFieldSet {
|
||||
export function partCategoryFields(): ApiFormFieldSet {
|
||||
let fields: ApiFormFieldSet = {
|
||||
parent: {
|
||||
description: t`Parent part category`,
|
||||
@@ -109,7 +132,9 @@ export function partCategoryFields({}: {}): ApiFormFieldSet {
|
||||
},
|
||||
default_keywords: {},
|
||||
structural: {},
|
||||
icon: {}
|
||||
icon: {
|
||||
field_type: 'icon'
|
||||
}
|
||||
};
|
||||
|
||||
return fields;
|
||||
@@ -160,6 +185,7 @@ export function usePartParameterFields(): ApiFormFieldSet {
|
||||
}
|
||||
},
|
||||
data: {
|
||||
type: fieldType,
|
||||
field_type: fieldType,
|
||||
choices: fieldType === 'choice' ? choices : undefined,
|
||||
adjustValue: (value: any) => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import {
|
||||
Container,
|
||||
Flex,
|
||||
FocusTrap,
|
||||
Group,
|
||||
Modal,
|
||||
NumberInput,
|
||||
Table,
|
||||
@@ -39,7 +41,10 @@ import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { ModelType } from '../enums/ModelType';
|
||||
import { InvenTreeIcon } from '../functions/icons';
|
||||
import { useCreateApiFormModal } from '../hooks/UseForm';
|
||||
import { useBatchCodeGenerator } from '../hooks/UseGenerator';
|
||||
import {
|
||||
useBatchCodeGenerator,
|
||||
useSerialNumberGenerator
|
||||
} from '../hooks/UseGenerator';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
|
||||
/*
|
||||
@@ -173,6 +178,9 @@ export function usePurchaseOrderFields(): ApiFormFieldSet {
|
||||
}
|
||||
},
|
||||
responsible: {
|
||||
filters: {
|
||||
is_active: true
|
||||
},
|
||||
icon: <IconUsers />
|
||||
}
|
||||
};
|
||||
@@ -219,12 +227,30 @@ function LineItemFormRow({
|
||||
}
|
||||
});
|
||||
|
||||
const serialNumberGenerator = useSerialNumberGenerator((value: any) => {
|
||||
if (!serials) {
|
||||
setSerials(value);
|
||||
}
|
||||
});
|
||||
|
||||
const [packagingOpen, packagingHandlers] = useDisclosure(false, {
|
||||
onClose: () => {
|
||||
input.changeFn(input.idx, 'packaging', undefined);
|
||||
}
|
||||
});
|
||||
|
||||
const [noteOpen, noteHandlers] = useDisclosure(false, {
|
||||
onClose: () => {
|
||||
input.changeFn(input.idx, 'note', undefined);
|
||||
}
|
||||
});
|
||||
|
||||
// State for serializing
|
||||
const [batchCode, setBatchCode] = useState<string>('');
|
||||
const [serials, setSerials] = useState<string>('');
|
||||
const [batchOpen, batchHandlers] = useDisclosure(false, {
|
||||
onClose: () => {
|
||||
input.changeFn(input.idx, 'batch_code', '');
|
||||
input.changeFn(input.idx, 'batch_code', undefined);
|
||||
input.changeFn(input.idx, 'serial_numbers', '');
|
||||
},
|
||||
onOpen: () => {
|
||||
@@ -233,19 +259,14 @@ function LineItemFormRow({
|
||||
part: record?.supplier_part_detail?.part,
|
||||
order: record?.order
|
||||
});
|
||||
// Generate new serial numbers
|
||||
serialNumberGenerator.update({
|
||||
part: record?.supplier_part_detail?.part,
|
||||
quantity: input.item.quantity
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Change form value when state is altered
|
||||
useEffect(() => {
|
||||
input.changeFn(input.idx, 'batch_code', batchCode);
|
||||
}, [batchCode]);
|
||||
|
||||
// Change form value when state is altered
|
||||
useEffect(() => {
|
||||
input.changeFn(input.idx, 'serial_numbers', serials);
|
||||
}, [serials]);
|
||||
|
||||
// Status value
|
||||
const [statusOpen, statusHandlers] = useDisclosure(false, {
|
||||
onClose: () => input.changeFn(input.idx, 'status', 10)
|
||||
@@ -361,27 +382,43 @@ function LineItemFormRow({
|
||||
<Table.Td style={{ width: '1%', whiteSpace: 'nowrap' }}>
|
||||
<Flex gap="1px">
|
||||
<ActionButton
|
||||
size="sm"
|
||||
onClick={() => locationHandlers.toggle()}
|
||||
icon={<InvenTreeIcon icon="location" />}
|
||||
tooltip={t`Set Location`}
|
||||
tooltipAlignment="top"
|
||||
variant={locationOpen ? 'filled' : 'outline'}
|
||||
variant={locationOpen ? 'filled' : 'transparent'}
|
||||
/>
|
||||
<ActionButton
|
||||
size="sm"
|
||||
onClick={() => batchHandlers.toggle()}
|
||||
icon={<InvenTreeIcon icon="batch_code" />}
|
||||
tooltip={t`Assign Batch Code${
|
||||
record.trackable && ' and Serial Numbers'
|
||||
}`}
|
||||
tooltipAlignment="top"
|
||||
variant={batchOpen ? 'filled' : 'outline'}
|
||||
variant={batchOpen ? 'filled' : 'transparent'}
|
||||
/>
|
||||
<ActionButton
|
||||
size="sm"
|
||||
icon={<InvenTreeIcon icon="packaging" />}
|
||||
tooltip={t`Adjust Packaging`}
|
||||
onClick={() => packagingHandlers.toggle()}
|
||||
variant={packagingOpen ? 'filled' : 'transparent'}
|
||||
/>
|
||||
<ActionButton
|
||||
onClick={() => statusHandlers.toggle()}
|
||||
icon={<InvenTreeIcon icon="status" />}
|
||||
tooltip={t`Change Status`}
|
||||
tooltipAlignment="top"
|
||||
variant={statusOpen ? 'filled' : 'outline'}
|
||||
variant={statusOpen ? 'filled' : 'transparent'}
|
||||
/>
|
||||
<ActionButton
|
||||
icon={<InvenTreeIcon icon="note" />}
|
||||
tooltip={t`Add Note`}
|
||||
tooltipAlignment="top"
|
||||
variant={noteOpen ? 'filled' : 'transparent'}
|
||||
onClick={() => noteHandlers.toggle()}
|
||||
/>
|
||||
{barcode ? (
|
||||
<ActionButton
|
||||
@@ -397,7 +434,7 @@ function LineItemFormRow({
|
||||
icon={<InvenTreeIcon icon="barcode" />}
|
||||
tooltip={t`Scan Barcode`}
|
||||
tooltipAlignment="top"
|
||||
variant="outline"
|
||||
variant="transparent"
|
||||
onClick={() => open()}
|
||||
/>
|
||||
)}
|
||||
@@ -413,33 +450,34 @@ function LineItemFormRow({
|
||||
</Table.Tr>
|
||||
{locationOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Flex align="end" gap={5}>
|
||||
<div style={{ flexGrow: '1' }}>
|
||||
<StandaloneField
|
||||
fieldDefinition={{
|
||||
field_type: 'related field',
|
||||
model: ModelType.stocklocation,
|
||||
api_url: apiUrl(ApiEndpoints.stock_location_list),
|
||||
filters: {
|
||||
structural: false
|
||||
},
|
||||
onValueChange: (value) => {
|
||||
setLocation(value);
|
||||
},
|
||||
description: locationDescription,
|
||||
value: location,
|
||||
label: t`Location`,
|
||||
icon: <InvenTreeIcon icon="location" />
|
||||
}}
|
||||
defaultValue={
|
||||
record.destination ??
|
||||
(record.destination_detail
|
||||
? record.destination_detail.pk
|
||||
: null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Table.Td colSpan={10}>
|
||||
<Group grow preventGrowOverflow={false} justify="flex-apart" p="xs">
|
||||
<Container flex={0} p="xs">
|
||||
<InvenTreeIcon icon="downright" />
|
||||
</Container>
|
||||
<StandaloneField
|
||||
fieldDefinition={{
|
||||
field_type: 'related field',
|
||||
model: ModelType.stocklocation,
|
||||
api_url: apiUrl(ApiEndpoints.stock_location_list),
|
||||
filters: {
|
||||
structural: false
|
||||
},
|
||||
onValueChange: (value) => {
|
||||
setLocation(value);
|
||||
},
|
||||
description: locationDescription,
|
||||
value: location,
|
||||
label: t`Location`,
|
||||
icon: <InvenTreeIcon icon="location" />
|
||||
}}
|
||||
defaultValue={
|
||||
record.destination ??
|
||||
(record.destination_detail
|
||||
? record.destination_detail.pk
|
||||
: null)
|
||||
}
|
||||
/>
|
||||
<Flex style={{ marginBottom: '7px' }}>
|
||||
{(record.part_detail.default_location ||
|
||||
record.part_detail.category_default_location) && (
|
||||
@@ -474,67 +512,57 @@ function LineItemFormRow({
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(6, 1fr)',
|
||||
gridTemplateRows: 'auto',
|
||||
alignItems: 'end'
|
||||
}}
|
||||
>
|
||||
<InvenTreeIcon icon="downleft" />
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
<TableFieldExtraRow
|
||||
visible={batchOpen}
|
||||
colSpan={4}
|
||||
content={
|
||||
<StandaloneField
|
||||
fieldDefinition={{
|
||||
field_type: 'string',
|
||||
onValueChange: (value) => setBatchCode(value),
|
||||
label: 'Batch Code',
|
||||
value: batchCode
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onValueChange={(value) => input.changeFn(input.idx, 'batch', value)}
|
||||
fieldDefinition={{
|
||||
field_type: 'string',
|
||||
label: t`Batch Code`,
|
||||
value: batchCode
|
||||
}}
|
||||
/>
|
||||
<TableFieldExtraRow
|
||||
visible={batchOpen && record.trackable}
|
||||
colSpan={4}
|
||||
content={
|
||||
<StandaloneField
|
||||
fieldDefinition={{
|
||||
field_type: 'string',
|
||||
onValueChange: (value) => setSerials(value),
|
||||
label: 'Serial numbers',
|
||||
value: serials
|
||||
}}
|
||||
/>
|
||||
onValueChange={(value) =>
|
||||
input.changeFn(input.idx, 'serial_numbers', value)
|
||||
}
|
||||
fieldDefinition={{
|
||||
field_type: 'string',
|
||||
label: t`Serial numbers`,
|
||||
value: serials
|
||||
}}
|
||||
/>
|
||||
<TableFieldExtraRow
|
||||
visible={packagingOpen}
|
||||
onValueChange={(value) => input.changeFn(input.idx, 'packaging', value)}
|
||||
fieldDefinition={{
|
||||
field_type: 'string',
|
||||
label: t`Packaging`
|
||||
}}
|
||||
defaultValue={record?.supplier_part_detail?.packaging}
|
||||
/>
|
||||
<TableFieldExtraRow
|
||||
visible={statusOpen}
|
||||
colSpan={4}
|
||||
content={
|
||||
<StandaloneField
|
||||
fieldDefinition={{
|
||||
field_type: 'choice',
|
||||
api_url: apiUrl(ApiEndpoints.stock_status),
|
||||
choices: statuses,
|
||||
label: 'Status',
|
||||
onValueChange: (value) =>
|
||||
input.changeFn(input.idx, 'status', value)
|
||||
}}
|
||||
defaultValue={10}
|
||||
/>
|
||||
}
|
||||
defaultValue={10}
|
||||
onValueChange={(value) => input.changeFn(input.idx, 'status', value)}
|
||||
fieldDefinition={{
|
||||
field_type: 'choice',
|
||||
api_url: apiUrl(ApiEndpoints.stock_status),
|
||||
choices: statuses,
|
||||
label: t`Status`
|
||||
}}
|
||||
/>
|
||||
<TableFieldExtraRow
|
||||
visible={noteOpen}
|
||||
onValueChange={(value) => input.changeFn(input.idx, 'note', value)}
|
||||
fieldDefinition={{
|
||||
field_type: 'string',
|
||||
label: t`Note`
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -608,7 +636,7 @@ export function useReceiveLineItems(props: LineItemsForm) {
|
||||
/>
|
||||
);
|
||||
},
|
||||
headers: ['Part', 'SKU', 'Received', 'Quantity to receive', 'Actions']
|
||||
headers: [t`Part`, t`SKU`, t`Received`, t`Quantity`, t`Actions`]
|
||||
},
|
||||
location: {
|
||||
filters: {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { IconUsers } from '@tabler/icons-react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
export function useReturnOrderLineItemFields({
|
||||
orderId,
|
||||
customerId,
|
||||
create
|
||||
}: {
|
||||
orderId: number;
|
||||
customerId: number;
|
||||
create?: boolean;
|
||||
}) {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
order: {
|
||||
disabled: true,
|
||||
filters: {
|
||||
customer_detail: true
|
||||
}
|
||||
},
|
||||
item: {
|
||||
filters: {
|
||||
customer: customerId,
|
||||
part_detail: true,
|
||||
serialized: true
|
||||
}
|
||||
},
|
||||
reference: {},
|
||||
outcome: {
|
||||
hidden: create == true
|
||||
},
|
||||
price: {},
|
||||
price_currency: {},
|
||||
target_date: {},
|
||||
notes: {},
|
||||
link: {},
|
||||
responsible: {
|
||||
filters: {
|
||||
is_active: true
|
||||
},
|
||||
icon: <IconUsers />
|
||||
}
|
||||
};
|
||||
}, [create, orderId, customerId]);
|
||||
}
|
||||
@@ -47,6 +47,60 @@ export function useSalesOrderFields(): ApiFormFieldSet {
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useSalesOrderLineItemFields({
|
||||
customerId,
|
||||
orderId,
|
||||
create
|
||||
}: {
|
||||
customerId?: number;
|
||||
orderId?: number;
|
||||
create?: boolean;
|
||||
}): ApiFormFieldSet {
|
||||
const fields = useMemo(() => {
|
||||
return {
|
||||
order: {
|
||||
filters: {
|
||||
customer_detail: true
|
||||
},
|
||||
disabled: true,
|
||||
value: create ? orderId : undefined
|
||||
},
|
||||
part: {
|
||||
filters: {
|
||||
active: true,
|
||||
salable: true
|
||||
}
|
||||
},
|
||||
reference: {},
|
||||
quantity: {},
|
||||
sale_price: {},
|
||||
sale_price_currency: {},
|
||||
target_date: {},
|
||||
notes: {},
|
||||
link: {}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function useSalesOrderShipmentFields(): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
order: {
|
||||
disabled: true
|
||||
},
|
||||
reference: {},
|
||||
shipment_date: {},
|
||||
delivery_date: {},
|
||||
tracking_number: {},
|
||||
invoice_number: {},
|
||||
link: {},
|
||||
notes: {}
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function useReturnOrderFields(): ApiFormFieldSet {
|
||||
return useMemo(() => {
|
||||
return {
|
||||
@@ -82,6 +136,9 @@ export function useReturnOrderFields(): ApiFormFieldSet {
|
||||
}
|
||||
},
|
||||
responsible: {
|
||||
filters: {
|
||||
is_active: true
|
||||
},
|
||||
icon: <IconUsers />
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Flex, Group, NumberInput, Skeleton, Table, Text } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
|
||||
import { Suspense, useCallback, useMemo, useState } from 'react';
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
ApiFormAdjustFilterType,
|
||||
ApiFormFieldSet
|
||||
} from '../components/forms/fields/ApiFormField';
|
||||
import { TableFieldExtraRow } from '../components/forms/fields/TableField';
|
||||
import { Thumbnail } from '../components/images/Thumbnail';
|
||||
import { StylishText } from '../components/items/StylishText';
|
||||
import { StatusRenderer } from '../components/render/StatusRenderer';
|
||||
@@ -26,6 +28,7 @@ import {
|
||||
useSerialNumberGenerator
|
||||
} from '../hooks/UseGenerator';
|
||||
import { apiUrl } from '../states/ApiState';
|
||||
import { useGlobalSettingsState } from '../states/SettingsState';
|
||||
|
||||
/**
|
||||
* Construct a set of fields for creating / editing a StockItem instance
|
||||
@@ -319,10 +322,30 @@ function StockOperationsRow({
|
||||
[item]
|
||||
);
|
||||
|
||||
const changeSubItem = useCallback(
|
||||
(key: string, value: any) => {
|
||||
input.changeFn(input.idx, key, value);
|
||||
},
|
||||
[input]
|
||||
);
|
||||
|
||||
const removeAndRefresh = () => {
|
||||
input.removeFn(input.idx);
|
||||
};
|
||||
|
||||
const [packagingOpen, packagingHandlers] = useDisclosure(false, {
|
||||
onOpen: () => {
|
||||
if (transfer) {
|
||||
input.changeFn(input.idx, 'packaging', record?.packaging || undefined);
|
||||
}
|
||||
},
|
||||
onClose: () => {
|
||||
if (transfer) {
|
||||
input.changeFn(input.idx, 'packaging', undefined);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const stockString: string = useMemo(() => {
|
||||
if (!record) {
|
||||
return '-';
|
||||
@@ -338,64 +361,91 @@ function StockOperationsRow({
|
||||
return !record ? (
|
||||
<div>{t`Loading...`}</div>
|
||||
) : (
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<Flex gap="sm" align="center">
|
||||
<Thumbnail
|
||||
size={40}
|
||||
src={record.part_detail?.thumbnail}
|
||||
align="center"
|
||||
/>
|
||||
<div>{record.part_detail?.name}</div>
|
||||
</Flex>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{record.location ? record.location_detail?.pathstring : '-'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Flex align="center" gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text>{stockString}</Text>
|
||||
<StatusRenderer status={record.status} type={ModelType.stockitem} />
|
||||
</Group>
|
||||
</Flex>
|
||||
</Table.Td>
|
||||
{!merge && (
|
||||
<>
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<NumberInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={!!record.serial && record.quantity == 1}
|
||||
max={setMax ? record.quantity : undefined}
|
||||
min={0}
|
||||
style={{ maxWidth: '100px' }}
|
||||
/>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Flex gap="3px">
|
||||
{transfer && (
|
||||
<ActionButton
|
||||
onClick={() => moveToDefault(record, value, removeAndRefresh)}
|
||||
icon={<InvenTreeIcon icon="default_location" />}
|
||||
tooltip={t`Move to default location`}
|
||||
tooltipAlignment="top"
|
||||
disabled={
|
||||
!record.part_detail?.default_location &&
|
||||
!record.part_detail?.category_default_location
|
||||
}
|
||||
<Flex gap="sm" align="center">
|
||||
<Thumbnail
|
||||
size={40}
|
||||
src={record.part_detail?.thumbnail}
|
||||
align="center"
|
||||
/>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={() => input.removeFn(input.idx)}
|
||||
icon={<InvenTreeIcon icon="square_x" />}
|
||||
tooltip={t`Remove item from list`}
|
||||
tooltipAlignment="top"
|
||||
color="red"
|
||||
/>
|
||||
</Flex>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
<div>{record.part_detail?.name}</div>
|
||||
</Flex>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{record.location ? record.location_detail?.pathstring : '-'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Flex align="center" gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text>{stockString}</Text>
|
||||
<StatusRenderer
|
||||
status={record.status}
|
||||
type={ModelType.stockitem}
|
||||
/>
|
||||
</Group>
|
||||
</Flex>
|
||||
</Table.Td>
|
||||
{!merge && (
|
||||
<Table.Td>
|
||||
<NumberInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={!!record.serial && record.quantity == 1}
|
||||
max={setMax ? record.quantity : undefined}
|
||||
min={0}
|
||||
style={{ maxWidth: '100px' }}
|
||||
/>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td>
|
||||
<Flex gap="3px">
|
||||
{transfer && (
|
||||
<ActionButton
|
||||
onClick={() => moveToDefault(record, value, removeAndRefresh)}
|
||||
icon={<InvenTreeIcon icon="default_location" />}
|
||||
tooltip={t`Move to default location`}
|
||||
tooltipAlignment="top"
|
||||
disabled={
|
||||
!record.part_detail?.default_location &&
|
||||
!record.part_detail?.category_default_location
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{transfer && (
|
||||
<ActionButton
|
||||
size="sm"
|
||||
icon={<InvenTreeIcon icon="packaging" />}
|
||||
tooltip={t`Adjust Packaging`}
|
||||
onClick={() => packagingHandlers.toggle()}
|
||||
variant={packagingOpen ? 'filled' : 'transparent'}
|
||||
/>
|
||||
)}
|
||||
<ActionButton
|
||||
onClick={() => input.removeFn(input.idx)}
|
||||
icon={<InvenTreeIcon icon="square_x" />}
|
||||
tooltip={t`Remove item from list`}
|
||||
tooltipAlignment="top"
|
||||
color="red"
|
||||
/>
|
||||
</Flex>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
{transfer && (
|
||||
<TableFieldExtraRow
|
||||
visible={transfer && packagingOpen}
|
||||
onValueChange={(value: any) => {
|
||||
input.changeFn(input.idx, 'packaging', value || undefined);
|
||||
}}
|
||||
fieldDefinition={{
|
||||
field_type: 'string',
|
||||
label: t`Packaging`
|
||||
}}
|
||||
defaultValue={record.packaging}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -850,7 +900,7 @@ export function useDeleteStockItem(props: StockOperationProps) {
|
||||
});
|
||||
}
|
||||
|
||||
export function stockLocationFields({}: {}): ApiFormFieldSet {
|
||||
export function stockLocationFields(): ApiFormFieldSet {
|
||||
let fields: ApiFormFieldSet = {
|
||||
parent: {
|
||||
description: t`Parent stock location`,
|
||||
@@ -860,7 +910,9 @@ export function stockLocationFields({}: {}): ApiFormFieldSet {
|
||||
description: {},
|
||||
structural: {},
|
||||
external: {},
|
||||
icon: {},
|
||||
custom_icon: {
|
||||
field_type: 'icon'
|
||||
},
|
||||
location_type: {}
|
||||
};
|
||||
|
||||
@@ -881,6 +933,13 @@ export function useTestResultFields({
|
||||
// Field type for the "value" input
|
||||
const [fieldType, setFieldType] = useState<'string' | 'choice'>('string');
|
||||
|
||||
const settings = useGlobalSettingsState.getState();
|
||||
|
||||
const includeTestStation = useMemo(
|
||||
() => settings.isSet('TEST_STATION_DATA'),
|
||||
[settings]
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
stock_item: {
|
||||
@@ -921,8 +980,15 @@ export function useTestResultFields({
|
||||
},
|
||||
attachment: {},
|
||||
notes: {},
|
||||
started_datetime: {},
|
||||
finished_datetime: {}
|
||||
started_datetime: {
|
||||
hidden: !includeTestStation
|
||||
},
|
||||
finished_datetime: {
|
||||
hidden: !includeTestStation
|
||||
},
|
||||
test_station: {
|
||||
hidden: !includeTestStation
|
||||
}
|
||||
};
|
||||
}, [choices, fieldType, partId, itemId]);
|
||||
}, [choices, fieldType, partId, itemId, includeTestStation]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import axios from 'axios';
|
||||
import { NavigateFunction } from 'react-router-dom';
|
||||
|
||||
import { api, setApiDefaults } from '../App';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
@@ -10,6 +11,35 @@ import { useUserState } from '../states/UserState';
|
||||
import { fetchGlobalStates } from '../states/states';
|
||||
import { showLoginNotification } from './notifications';
|
||||
|
||||
/**
|
||||
* sends a request to the specified url from a form. this will change the window location.
|
||||
* @param {string} path the path to send the post request to
|
||||
* @param {object} params the parameters to add to the url
|
||||
* @param {string} [method=post] the method to use on the form
|
||||
*
|
||||
* Source https://stackoverflow.com/questions/133925/javascript-post-request-like-a-form-submit/133997#133997
|
||||
*/
|
||||
|
||||
function post(path: string, params: any, method = 'post') {
|
||||
const form = document.createElement('form');
|
||||
form.method = method;
|
||||
form.action = path;
|
||||
|
||||
for (const key in params) {
|
||||
if (params.hasOwnProperty(key)) {
|
||||
const hiddenField = document.createElement('input');
|
||||
hiddenField.type = 'hidden';
|
||||
hiddenField.name = key;
|
||||
hiddenField.value = params[key];
|
||||
|
||||
form.appendChild(hiddenField);
|
||||
}
|
||||
}
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to login using username:password combination.
|
||||
* If login is successful, an API token will be returned.
|
||||
@@ -17,8 +47,7 @@ import { showLoginNotification } from './notifications';
|
||||
*/
|
||||
export const doBasicLogin = async (username: string, password: string) => {
|
||||
const { host } = useLocalState.getState();
|
||||
const { clearUserState, setToken, fetchUserState, isLoggedIn } =
|
||||
useUserState.getState();
|
||||
const { clearUserState, setToken, fetchUserState } = useUserState.getState();
|
||||
|
||||
if (username.length == 0 || password.length == 0) {
|
||||
return;
|
||||
@@ -50,11 +79,23 @@ export const doBasicLogin = async (username: string, password: string) => {
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch((err) => {
|
||||
if (
|
||||
err?.response?.status == 403 &&
|
||||
err?.response?.data?.detail == 'MFA required for this user'
|
||||
) {
|
||||
post(apiUrl(ApiEndpoints.user_login), {
|
||||
username: username,
|
||||
password: password,
|
||||
csrfmiddlewaretoken: getCsrfCookie(),
|
||||
mfa: true
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (result) {
|
||||
await fetchUserState();
|
||||
await fetchGlobalStates();
|
||||
fetchGlobalStates();
|
||||
} else {
|
||||
clearUserState();
|
||||
}
|
||||
@@ -65,7 +106,7 @@ export const doBasicLogin = async (username: string, password: string) => {
|
||||
*
|
||||
* @arg deleteToken: If true, delete the token from the server
|
||||
*/
|
||||
export const doLogout = async (navigate: any) => {
|
||||
export const doLogout = async (navigate: NavigateFunction) => {
|
||||
const { clearUserState, isLoggedIn } = useUserState.getState();
|
||||
|
||||
// Logout from the server session
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { t } from '@lingui/macro';
|
||||
import { Divider, Stack } from '@mantine/core';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { AxiosResponse } from 'axios';
|
||||
|
||||
import { api } from '../App';
|
||||
import { ApiForm, ApiFormProps } from '../components/forms/ApiForm';
|
||||
import {
|
||||
ApiFormFieldSet,
|
||||
ApiFormFieldType
|
||||
} from '../components/forms/fields/ApiFormField';
|
||||
import { StylishText } from '../components/items/StylishText';
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { PathParams, apiUrl } from '../states/ApiState';
|
||||
import { invalidResponse, permissionDenied } from './notifications';
|
||||
import { generateUniqueId } from './uid';
|
||||
|
||||
/**
|
||||
* Construct an API url from the provided ApiFormProps object
|
||||
@@ -64,32 +56,41 @@ export function extractAvailableFields(
|
||||
return null;
|
||||
}
|
||||
|
||||
const processField = (field: any, fieldName: string) => {
|
||||
const resField: ApiFormFieldType = {
|
||||
...field,
|
||||
name: fieldName,
|
||||
field_type: field.type,
|
||||
description: field.help_text,
|
||||
value: field.value ?? field.default,
|
||||
disabled: field.read_only ?? false
|
||||
};
|
||||
|
||||
// Remove the 'read_only' field - plays havoc with react components
|
||||
delete resField.read_only;
|
||||
|
||||
if (resField.field_type === 'nested object' && resField.children) {
|
||||
resField.children = processFields(resField.children, fieldName);
|
||||
}
|
||||
|
||||
if (resField.field_type === 'dependent field' && resField.child) {
|
||||
resField.child = processField(resField.child, fieldName);
|
||||
|
||||
// copy over the label from the dependent field to the child field
|
||||
if (!resField.child.label) {
|
||||
resField.child.label = resField.label;
|
||||
}
|
||||
}
|
||||
|
||||
return resField;
|
||||
};
|
||||
|
||||
const processFields = (fields: any, _path?: string) => {
|
||||
const _fields: ApiFormFieldSet = {};
|
||||
|
||||
for (const [fieldName, field] of Object.entries(fields) as any) {
|
||||
const path = _path ? `${_path}.${fieldName}` : fieldName;
|
||||
_fields[fieldName] = {
|
||||
...field,
|
||||
name: path,
|
||||
field_type: field.type,
|
||||
description: field.help_text,
|
||||
value: field.value ?? field.default,
|
||||
disabled: field.read_only ?? false
|
||||
};
|
||||
|
||||
// Remove the 'read_only' field - plays havoc with react components
|
||||
delete _fields[fieldName].read_only;
|
||||
|
||||
if (
|
||||
_fields[fieldName].field_type === 'nested object' &&
|
||||
_fields[fieldName].children
|
||||
) {
|
||||
_fields[fieldName].children = processFields(
|
||||
_fields[fieldName].children,
|
||||
path
|
||||
);
|
||||
}
|
||||
_fields[fieldName] = processField(field, path);
|
||||
}
|
||||
|
||||
return _fields;
|
||||
@@ -153,6 +154,14 @@ export function constructField({
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'dependent field':
|
||||
if (!definition?.child) break;
|
||||
|
||||
def.child = constructField({
|
||||
// use the raw definition here as field, since a dependent field cannot be influenced by the frontend
|
||||
field: definition.child ?? {}
|
||||
});
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -163,143 +172,3 @@ export function constructField({
|
||||
|
||||
return def;
|
||||
}
|
||||
|
||||
export interface OpenApiFormProps extends ApiFormProps {
|
||||
title: string;
|
||||
cancelText?: string;
|
||||
cancelColor?: string;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/*
|
||||
* Construct and open a modal form
|
||||
* @param title :
|
||||
*/
|
||||
export function openModalApiForm(props: OpenApiFormProps) {
|
||||
// method property *must* be supplied
|
||||
if (!props.method) {
|
||||
notifications.show({
|
||||
title: t`Invalid Form`,
|
||||
message: t`method parameter not supplied`,
|
||||
color: 'red'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate a random modal ID for controller
|
||||
let modalId: string =
|
||||
`modal-${props.title}-${props.url}-${props.method}` + generateUniqueId();
|
||||
|
||||
props.actions = [
|
||||
...(props.actions || []),
|
||||
{
|
||||
text: props.cancelText ?? t`Cancel`,
|
||||
color: props.cancelColor ?? 'blue',
|
||||
onClick: () => {
|
||||
modals.close(modalId);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const oldFormSuccess = props.onFormSuccess;
|
||||
props.onFormSuccess = (data) => {
|
||||
oldFormSuccess?.(data);
|
||||
modals.close(modalId);
|
||||
};
|
||||
|
||||
let url = constructFormUrl(props.url, props.pk, props.pathParams);
|
||||
|
||||
// Make OPTIONS request first
|
||||
api
|
||||
.options(url)
|
||||
.then((response) => {
|
||||
// Extract available fields from the OPTIONS response (and handle any errors)
|
||||
|
||||
let fields: Record<string, ApiFormFieldType> | null = {};
|
||||
|
||||
if (!props.ignorePermissionCheck) {
|
||||
fields = extractAvailableFields(response, props.method);
|
||||
|
||||
if (fields == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const _props = { ...props };
|
||||
|
||||
if (_props.fields) {
|
||||
for (const [k, v] of Object.entries(_props.fields)) {
|
||||
_props.fields[k] = constructField({
|
||||
field: v,
|
||||
definition: fields?.[k]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
modals.open({
|
||||
title: <StylishText size="xl">{props.title}</StylishText>,
|
||||
modalId: modalId,
|
||||
size: 'xl',
|
||||
onClose: () => {
|
||||
props.onClose ? props.onClose() : null;
|
||||
},
|
||||
children: (
|
||||
<Stack gap={'xs'}>
|
||||
<Divider />
|
||||
<ApiForm id={modalId} props={props} optionsLoading={false} />
|
||||
</Stack>
|
||||
)
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error.response) {
|
||||
invalidResponse(error.response.status);
|
||||
} else {
|
||||
notifications.show({
|
||||
title: t`Form Error`,
|
||||
message: error.message,
|
||||
color: 'red'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens a modal form to create a new model instance
|
||||
*/
|
||||
export function openCreateApiForm(props: OpenApiFormProps) {
|
||||
let createProps: OpenApiFormProps = {
|
||||
...props,
|
||||
method: 'POST'
|
||||
};
|
||||
|
||||
openModalApiForm(createProps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a modal form to edit a model instance
|
||||
*/
|
||||
export function openEditApiForm(props: OpenApiFormProps) {
|
||||
let editProps: OpenApiFormProps = {
|
||||
...props,
|
||||
fetchInitialData: props.fetchInitialData ?? true,
|
||||
method: 'PATCH'
|
||||
};
|
||||
|
||||
openModalApiForm(editProps);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a modal form to delete a model instancel
|
||||
*/
|
||||
export function openDeleteApiForm(props: OpenApiFormProps) {
|
||||
let deleteProps: OpenApiFormProps = {
|
||||
...props,
|
||||
method: 'DELETE',
|
||||
submitText: t`Delete`,
|
||||
submitColor: 'red',
|
||||
fields: {}
|
||||
};
|
||||
|
||||
openModalApiForm(deleteProps);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
IconBinaryTree2,
|
||||
IconBookmarks,
|
||||
IconBox,
|
||||
IconBrandTelegram,
|
||||
IconBuilding,
|
||||
IconBuildingFactory2,
|
||||
IconBuildingStore,
|
||||
@@ -32,12 +33,15 @@ import {
|
||||
IconFlagShare,
|
||||
IconGitBranch,
|
||||
IconGridDots,
|
||||
IconHandStop,
|
||||
IconHash,
|
||||
IconHierarchy,
|
||||
IconInfoCircle,
|
||||
IconLayersLinked,
|
||||
IconLink,
|
||||
IconList,
|
||||
IconListTree,
|
||||
IconLock,
|
||||
IconMail,
|
||||
IconMapPin,
|
||||
IconMapPinHeart,
|
||||
@@ -89,7 +93,8 @@ import React from 'react';
|
||||
const icons = {
|
||||
name: IconPoint,
|
||||
description: IconInfoCircle,
|
||||
variant_of: IconStatusChange,
|
||||
variant_of: IconHierarchy,
|
||||
revision_of: IconStatusChange,
|
||||
unallocated_stock: IconPackage,
|
||||
total_in_stock: IconPackages,
|
||||
minimum_stock: IconFlag,
|
||||
@@ -140,6 +145,10 @@ const icons = {
|
||||
plus: IconCirclePlus,
|
||||
minus: IconCircleMinus,
|
||||
cancel: IconCircleX,
|
||||
hold: IconHandStop,
|
||||
issue: IconBrandTelegram,
|
||||
complete: IconCircleCheck,
|
||||
deliver: IconTruckDelivery,
|
||||
|
||||
// Part Icons
|
||||
active: IconCheck,
|
||||
@@ -153,6 +162,8 @@ const icons = {
|
||||
inactive: IconX,
|
||||
part: IconBox,
|
||||
supplier_part: IconPackageImport,
|
||||
lock: IconLock,
|
||||
locked: IconLock,
|
||||
|
||||
calendar: IconCalendar,
|
||||
external: IconExternalLink,
|
||||
|
||||
@@ -22,6 +22,7 @@ type UseFilterProps = {
|
||||
export function useFilters(props: UseFilterProps) {
|
||||
const query = useQuery({
|
||||
enabled: true,
|
||||
gcTime: 500,
|
||||
queryKey: [props.url, props.method, props.params],
|
||||
queryFn: async () => {
|
||||
return await api
|
||||
@@ -44,7 +45,15 @@ export function useFilters(props: UseFilterProps) {
|
||||
});
|
||||
|
||||
const choices: TableFilterChoice[] = useMemo(() => {
|
||||
return query.data?.map(props.transform) ?? [];
|
||||
let opts = query.data?.map(props.transform) ?? [];
|
||||
|
||||
// Ensure stringiness
|
||||
return opts.map((opt: any) => {
|
||||
return {
|
||||
value: opt.value.toString(),
|
||||
label: opt?.label?.toString() ?? opt.value.toString()
|
||||
};
|
||||
});
|
||||
}, [props.transform, query.data]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
@@ -72,6 +81,9 @@ export function useProjectCodeFilters() {
|
||||
export function useUserFilters() {
|
||||
return useFilters({
|
||||
url: apiUrl(ApiEndpoints.user_list),
|
||||
params: {
|
||||
is_active: true
|
||||
},
|
||||
transform: (item) => ({
|
||||
value: item.pk,
|
||||
label: item.username
|
||||
@@ -83,6 +95,9 @@ export function useUserFilters() {
|
||||
export function useOwnerFilters() {
|
||||
return useFilters({
|
||||
url: apiUrl(ApiEndpoints.owner_list),
|
||||
params: {
|
||||
is_active: true
|
||||
},
|
||||
transform: (item) => ({
|
||||
value: item.pk,
|
||||
label: item.name
|
||||
|
||||
@@ -42,13 +42,15 @@ export function useGenerator(
|
||||
...params
|
||||
}));
|
||||
}
|
||||
|
||||
queryGenerator.refetch();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// API query handler
|
||||
const queryGenerator = useQuery({
|
||||
enabled: true,
|
||||
enabled: false,
|
||||
queryKey: ['generator', key, endpoint, debouncedQuery],
|
||||
queryFn: async () => {
|
||||
return api.post(apiUrl(endpoint), debouncedQuery).then((response) => {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { ApiEndpoints } from '../enums/ApiEndpoints';
|
||||
import { ModelType } from '../enums/ModelType';
|
||||
import { useInstance } from './UseInstance';
|
||||
import useStatusCodes from './UseStatusCodes';
|
||||
|
||||
/*
|
||||
* Custom hook for managing the state of a data import session
|
||||
*/
|
||||
|
||||
export type ImportSessionState = {
|
||||
sessionId: number;
|
||||
sessionData: any;
|
||||
setSessionData: (data: any) => void;
|
||||
refreshSession: () => void;
|
||||
sessionQuery: any;
|
||||
status: number;
|
||||
availableFields: Record<string, any>;
|
||||
availableColumns: string[];
|
||||
mappedFields: any[];
|
||||
columnMappings: any[];
|
||||
fieldDefaults: any;
|
||||
fieldOverrides: any;
|
||||
fieldFilters: any;
|
||||
rowCount: number;
|
||||
completedRowCount: number;
|
||||
};
|
||||
|
||||
export function useImportSession({
|
||||
sessionId
|
||||
}: {
|
||||
sessionId: number;
|
||||
}): ImportSessionState {
|
||||
// Query manager for the import session
|
||||
const {
|
||||
instance: sessionData,
|
||||
setInstance,
|
||||
refreshInstance: refreshSession,
|
||||
instanceQuery: sessionQuery
|
||||
} = useInstance({
|
||||
endpoint: ApiEndpoints.import_session_list,
|
||||
pk: sessionId,
|
||||
defaultValue: {}
|
||||
});
|
||||
|
||||
const setSessionData = useCallback((data: any) => {
|
||||
setInstance(data);
|
||||
}, []);
|
||||
|
||||
const importSessionStatus = useStatusCodes({
|
||||
modelType: ModelType.importsession
|
||||
});
|
||||
|
||||
// Current step of the import process
|
||||
const status: number = useMemo(() => {
|
||||
return sessionData?.status ?? importSessionStatus.INITIAL;
|
||||
}, [sessionData, importSessionStatus]);
|
||||
|
||||
// List of available writeable database field definitions
|
||||
const availableFields: any[] = useMemo(() => {
|
||||
return sessionData?.available_fields ?? [];
|
||||
}, [sessionData]);
|
||||
|
||||
// List of available data file columns
|
||||
const availableColumns: string[] = useMemo(() => {
|
||||
let cols = sessionData?.columns ?? [];
|
||||
|
||||
// Filter out any blank or duplicate columns
|
||||
cols = cols.filter((col: string) => !!col);
|
||||
cols = cols.filter(
|
||||
(col: string, index: number) => cols.indexOf(col) === index
|
||||
);
|
||||
|
||||
return cols;
|
||||
}, [sessionData.columns]);
|
||||
|
||||
const columnMappings: any[] = useMemo(() => {
|
||||
let mapping =
|
||||
sessionData?.column_mappings?.map((mapping: any) => ({
|
||||
...mapping,
|
||||
...(availableFields[mapping.field] ?? {})
|
||||
})) ?? [];
|
||||
|
||||
mapping = mapping.sort((a: any, b: any) => {
|
||||
if (a?.required && !b?.required) return -1;
|
||||
if (!a?.required && b?.required) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
return mapping;
|
||||
}, [sessionData, availableColumns]);
|
||||
|
||||
// List of field which have been mapped to columns
|
||||
const mappedFields: any[] = useMemo(() => {
|
||||
return (
|
||||
sessionData?.column_mappings?.filter((column: any) => !!column.column) ??
|
||||
[]
|
||||
);
|
||||
}, [sessionData]);
|
||||
|
||||
const fieldDefaults: any = useMemo(() => {
|
||||
return sessionData?.field_defaults ?? {};
|
||||
}, [sessionData]);
|
||||
|
||||
const fieldOverrides: any = useMemo(() => {
|
||||
return sessionData?.field_overrides ?? {};
|
||||
}, [sessionData]);
|
||||
|
||||
const fieldFilters: any = useMemo(() => {
|
||||
return sessionData?.field_filters ?? {};
|
||||
}, [sessionData]);
|
||||
|
||||
const rowCount: number = useMemo(() => {
|
||||
return sessionData?.row_count ?? 0;
|
||||
}, [sessionData]);
|
||||
|
||||
const completedRowCount: number = useMemo(() => {
|
||||
return sessionData?.completed_row_count ?? 0;
|
||||
}, [sessionData]);
|
||||
|
||||
return {
|
||||
sessionData,
|
||||
setSessionData,
|
||||
sessionId,
|
||||
refreshSession,
|
||||
sessionQuery,
|
||||
status,
|
||||
availableFields,
|
||||
availableColumns,
|
||||
columnMappings,
|
||||
mappedFields,
|
||||
fieldDefaults,
|
||||
fieldOverrides,
|
||||
fieldFilters,
|
||||
rowCount,
|
||||
completedRowCount
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,8 @@ export function useInstance<T = any>({
|
||||
}) {
|
||||
const [instance, setInstance] = useState<T | undefined>(defaultValue);
|
||||
|
||||
const [requestStatus, setRequestStatus] = useState<number>(0);
|
||||
|
||||
const instanceQuery = useQuery<T>({
|
||||
queryKey: ['instance', endpoint, pk, params, pathParams],
|
||||
queryFn: async () => {
|
||||
@@ -62,6 +64,7 @@ export function useInstance<T = any>({
|
||||
params: params
|
||||
})
|
||||
.then((response) => {
|
||||
setRequestStatus(response.status);
|
||||
switch (response.status) {
|
||||
case 200:
|
||||
setInstance(response.data);
|
||||
@@ -72,8 +75,9 @@ export function useInstance<T = any>({
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
setRequestStatus(error.response?.status || 0);
|
||||
setInstance(defaultValue);
|
||||
console.error(`Error fetching instance ${url}:`, error);
|
||||
console.error(`ERR: Error fetching instance ${url}:`, error);
|
||||
|
||||
if (throwError) throw error;
|
||||
|
||||
@@ -81,7 +85,7 @@ export function useInstance<T = any>({
|
||||
});
|
||||
},
|
||||
refetchOnMount: refetchOnMount,
|
||||
refetchOnWindowFocus: refetchOnWindowFocus,
|
||||
refetchOnWindowFocus: refetchOnWindowFocus ?? false,
|
||||
refetchInterval: updateInterval
|
||||
});
|
||||
|
||||
@@ -89,5 +93,11 @@ export function useInstance<T = any>({
|
||||
instanceQuery.refetch();
|
||||
}, []);
|
||||
|
||||
return { instance, refreshInstance, instanceQuery };
|
||||
return {
|
||||
instance,
|
||||
setInstance,
|
||||
refreshInstance,
|
||||
instanceQuery,
|
||||
requestStatus
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { getStatusCodes } from '../components/render/StatusRenderer';
|
||||
import { ModelType } from '../enums/ModelType';
|
||||
import { useGlobalStatusState } from '../states/StatusState';
|
||||
|
||||
/**
|
||||
* Hook to access status codes, which are enumerated by the backend.
|
||||
*
|
||||
* This hook is used to return a map of status codes for a given model type.
|
||||
* It is a memoized wrapper around getStatusCodes,
|
||||
* and returns a simplified KEY:value map of status codes.
|
||||
*
|
||||
* e.g. for the "PurchaseOrderStatus" enumeration, returns a map like:
|
||||
*
|
||||
* {
|
||||
* PENDING: 10
|
||||
* PLACED: 20
|
||||
* ON_HOLD: 25,
|
||||
* COMPLETE: 30,
|
||||
* CANCELLED: 40,
|
||||
* LOST: 50,
|
||||
* RETURNED: 60
|
||||
* }
|
||||
*/
|
||||
export default function useStatusCodes({
|
||||
modelType
|
||||
}: {
|
||||
modelType: ModelType | string;
|
||||
}) {
|
||||
const statusCodeList = useGlobalStatusState.getState().status;
|
||||
|
||||
const codes = useMemo(() => {
|
||||
const statusCodes = getStatusCodes(modelType) || {};
|
||||
|
||||
let codesMap: Record<any, any> = {};
|
||||
|
||||
for (let name in statusCodes) {
|
||||
codesMap[name] = statusCodes[name].key;
|
||||
}
|
||||
|
||||
return codesMap;
|
||||
}, [modelType, statusCodeList]);
|
||||
|
||||
return codes;
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export function useTable(tableName: string): TableState {
|
||||
// Callback used to refresh (reload) the table
|
||||
const refreshTable = useCallback(() => {
|
||||
setTableKey(generateTableName());
|
||||
}, []);
|
||||
}, [generateTableName]);
|
||||
|
||||
// Array of active filters (saved to local storage)
|
||||
const [activeFilters, setActiveFilters] = useLocalStorage<TableFilter[]>({
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
import { Messages } from '@lingui/core';
|
||||
declare const messages: Messages;
|
||||
export { messages };
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+2447
-1473
File diff suppressed because it is too large
Load Diff
+2496
-1522
File diff suppressed because it is too large
Load Diff
+2447
-1473
File diff suppressed because it is too large
Load Diff
+2491
-1517
File diff suppressed because it is too large
Load Diff
+2448
-1474
File diff suppressed because it is too large
Load Diff
+2421
-1464
File diff suppressed because it is too large
Load Diff
+2408
-1451
File diff suppressed because it is too large
Load Diff
+2571
-1597
File diff suppressed because it is too large
Load Diff
+4
@@ -0,0 +1,4 @@
|
||||
import { Messages } from '@lingui/core';
|
||||
declare const messages: Messages;
|
||||
export { messages };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user