2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-06-17 20:45:44 +00:00

[WIP] Data importer (#6911)

* Adds new model for DataImportSession

* Add file extension validation

Expose to admin interface also

* Switch to new 'importer' app

* Refactoring to help prevent circular imports

* Add serializer registry

- Use @register_importer tag for any serializer class

* Cleanup migration file

- Do not use one-time hard-coded values here

* Refactor code into registry.py

* Add validation for the uploaded file

- Must be importable by tablib

* Refactoring

* Adds property to retrieve matching serializer class

* Update helper functions

* Add hook to auto-assign columns on initial creation

* Rename field

* Enforce initial status value

* Add model for individual rows in the data import

* Add DataImportRow model

* Extract data rows as dict

* Update fields

- Remove "progress" field (will be calculated)
- Added "timestamp" field
- Added "complete" field to DataImportRow

* Auto-map column names

- Provide "sensible" default values

* Add API endpoint for DataImportSession

* Offload data import operation

- For large data files this may take a significant amount of time
- Offload it to the background worker process

* Refactor data import code

* Update models

- Add "columns" field to DataImportSession
- Add "errors" field to DataImportRow

* Move field mapping to a new model type

- Simpler validation

* Save "valid" status for each data row

* Include session defaults when validating row data

* Update content_excludes

- Ignore importer models in import/export

* Remove port from ALLOWED_HOST entries

* Skip table events for importer models

* Bug fixes

* Serializer updates

* Add more endpoints

- DataImportColumnMappingList
- DataImportRowList

* further updates:

- Add 'get_api_url' method
- Handle case where

* Expose "available fields" to the DataImportSession serializer

Uses the (already available) inventree metadata middleware

* Add detail endpoints

* Clear existing column mappings

* Add endpoint for accepting column mappings

* Add API endpoint exposing available importer serializers

* Add simple playground area for testing data importer

* Adds simple form to start new import session

- Needs work, file field does not currently function correctly

* data_file is *not* read_only

* Add check for file type

* Remove debug statements

* Refactor column mapping

- Generate mapping for each column
- Remove "columns" field
- Column names are calculated dynamically

* Fix uniqueness requirements on mapping table

* Admin updates

- Prevent deletion of mappings
- Prevent addition of mappings

* API endpoint updates

- Prevent mappings from being deleted
- Prevent mappings from being created

* Update importer drawer

* Add widget for selecting data columns

* UI tweaks

* Delete import session when closing modal

* Allow empty string value

* Complete column mapping

* Adds ability to remove rows

* Adjust drawer specs

* Add column 'description' to serializer

* Add option to hide labels in API form field

* Update column heading

* Fix frontend linting errors

* Revert drawer position

* Return correct type

* Fix shadowing

* Fix f-string

* simplify frontend code

* Move importer app

* Update API version

* Reintroduce export formats

* Add new models to RuleSet

* typescript cleanup

* Typescript cleanup

* Improvement for Switch / boolean field

* Display original row data on popover

* Only display mapped columns

* Add DataExportMixin class

- Replaces existing APIDownloadMixin
- Uses DRF serializers for exporting
- *much* more efficient

* Create new file: importer.mixins.py

* Add new mixin to existing views which support data export

* Better error handling

* Cleanup:

- Remove references to APIDownloadMixin
- Remove download_queryset method
- All now handled by API-based export functionality

* Replace table with InvenTreeTable

- Paginate imported rows
- Data can be searched, ordered,

* Make 'pathstring' fields read-only

* Expose list of valid importer types to the API

* Exclude read-only fields

* Cleanup

* Updates for session model

- Column is now editable on mapping object
- Field is no  longer editable
- Improve admin integration

* Adds new custom hook for controlling data import session

* Refactor column mapping widget

* Refactor ImportDataSelector

* Working on ImportDataSelector component

* Adds method for editing fields in import table

- Cell edit mode
- Row edit mode
- Form submission still needs work!

* Adds background task for removing old import sessions

* Fix api_version.py

* Update src/frontend/src/components/importer/ImportDataSelector.tsx

Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>

* Update model verbose names

* Rename mixin class

* Add serializer mixin classes

- Will allow for fine-tuning of the import/export proces

* @register_importer requires specific mixin

* Check subclass for export

* Fix typos

* Refactor export serializer

- Keep operations local to the class

* Add shim class to process an individual row before exporting it

* Add mixin to existing serializers

* Add export functionality for company serializers

* Adds  placeholder for custom admin class

* Update mantine styling

* spacing -> gap

* Add functionality to pre-process form data before upload

* Remove old references to download_queryset

* Improvements for data import drawer:

- Pin title at top of drawer

* Further improvements

* Fix column selection input

* Formatting improvements

* Use a <Stepper> component for better progress display

* Cleanup text

* Add export-only fields to BuildItem queryset

* Expand "export" fields for BuildItem dataset

* Skip backup and static steps in CI

* Remove hard-coded paths

* Fix for "accept_mapping" method

* Present required fields first on import session

* Add "get_importable_fields" method

* Add method for commiting imported row to database

* Cleanup

* Save "complete" state after row import

* Allow prevention of column caching

* Remove debug statement

* Add basic admin table for import sessions

* Fix for table filter functions

- New mantine version requires string values

* Add filters for import session table

* Remove debug message

* fix for <FilterItem />

* Create new import session from admin page

* Cleanup playground

* Re-open an existing import session

* Memoize cell value

* Update <ImportDataSelector>

* Enable download of build line data

* Add extra detail fields

* Register data importers for the stock app

* Enable download of stock item tracking data

* Register importerrs for "company" app

* Register importers for the "order" app

* Add extra fields to purchase order line item serializer

* Update verbose names for order models

* Cleanup import data table rendering

* Pass session information through to cell renderer

* add separate 'field_overrides' field

* Expose 'field_overrides' to API

* Refactor import field selection

* Use override data if provided

* Fix data extraction

- Ignore columns which are not mapped

* Fix fields.pop

- Provide 'None' argument

* Update import data rendering

* Handle missing / empty column names when importing data

* Bug fixin'

* Update hook

* Adds button to upload data straight to table

* Cache "available_fields"

- Reduces API access time by 85%

* Fix calculation of completed_row_count

* Import individual rows from import session

* Allow import of multiple simultaneous records

* Improve extraction of metadata

- Especially for related fields
- Request object no longer required

* Implement suspended rendering of model instances

* Cleanup

* Implement more columns for StockTable

* Allow stock filtering by packaging field

* Fix "stock_value" column

* Improve metadata extraction

- Handle read_only_fields in Meta
- Handle write_only_fields in Meta

* Increase maximum number of importable rows

* Force data import to run on background worker

* Add export-only fields to StockItemSerializer class

* Data conversion when performing initial import

* Various tweaks

* Fix order of operations for data import

* Rename component

* Allow import/export of more model types

* Fix verbose name

* Import rows as a bulk db operation

* Enable download for PartCategoryTemplateTable

* Update stock item export

* Updates for unit tests

* Remove xls format for now

- Causes some bug in tablib
- Surely xlsx is OK?

* More unit test updates

* Future proof migration

* Updates

* unit tests

* Unit test fix

* Remove 'field_overrides'

- field_defaults will suffice

* Remove 'xls' as download option from frontend

* Add simple unit test for data import

* PUI tweaks

---------

Co-authored-by: Lukas <76838159+wolflu05@users.noreply.github.com>
This commit is contained in:
Oliver
2024-07-06 18:29:52 +10:00
committed by GitHub
parent 58f12f5ce5
commit 1f6cd9fc54
121 changed files with 3747 additions and 508 deletions

View File

@ -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}

View File

@ -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;
@ -386,6 +388,11 @@ export function ApiForm({
}
});
// Optionally pre-process the data before submitting it
if (props.processFormData) {
data = props.processFormData(data);
}
return api({
method: method,
url: url,

View File

@ -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>
);

View File

@ -102,11 +102,13 @@ export type ApiFormFieldType = {
export function ApiFormField({
fieldName,
definition,
control
control,
hideLabels
}: {
fieldName: string;
definition: ApiFormFieldType;
control: Control<FieldValues, any>;
hideLabels?: boolean;
}) {
const fieldId = useId();
const controller = useController({
@ -128,18 +130,26 @@ export function ApiFormField({
}
}, [definition.value]);
const fieldDefinition: ApiFormFieldType = useMemo(() => {
return {
...definition,
label: hideLabels ? undefined : definition.label,
description: hideLabels ? undefined : definition.description
};
}, [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(
@ -193,7 +203,7 @@ export function ApiFormField({
return (
<RelatedModelField
controller={controller}
definition={definition}
definition={fieldDefinition}
fieldName={fieldName}
/>
);
@ -228,14 +238,16 @@ export function ApiFormField({
aria-label={`boolean-field-${field.name}`}
radius="lg"
size="sm"
checked={isTrue(value)}
checked={isTrue(reducedDefinition.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,7 +289,7 @@ export function ApiFormField({
case 'nested object':
return (
<NestedObjectField
definition={definition}
definition={fieldDefinition}
fieldName={fieldName}
control={control}
/>
@ -285,7 +297,7 @@ export function ApiFormField({
case 'table':
return (
<TableField
definition={definition}
definition={fieldDefinition}
fieldName={fieldName}
control={controller}
/>
@ -293,8 +305,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>
);
}

View File

@ -65,6 +65,7 @@ export function ChoiceField({
disabled={definition.disabled}
leftSection={definition.icon}
comboboxProps={{ withinPortal: true }}
searchable
/>
);
}

View File

@ -0,0 +1,397 @@
import { t } from '@lingui/macro';
import { Group, HoverCard, 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 { 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) {
fields[field] = {
...fieldDef,
field_type: fieldDef.type,
description: fieldDef.help_text
};
}
}
return fields;
}, [selectedFieldNames, session.availableFields]);
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.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,
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">
<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
}}
/>
</Stack>
</>
);
}

View File

@ -0,0 +1,144 @@
import { t } from '@lingui/macro';
import {
Alert,
Button,
Divider,
Group,
Select,
SimpleGrid,
Stack,
Text
} from '@mantine/core';
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';
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
placeholder={t`Select column, or leave blank to ignore this field.`}
label={undefined}
data={options}
value={selectedColumn}
onChange={onChange}
/>
);
}
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`Select a column from the data file` },
...session.availableColumns.map((column: any) => {
return {
value: column,
label: column
};
})
];
}, [session.availableColumns]);
return (
<Stack gap="xs">
<Group justify="apart">
<Text>{t`Map data columns to database fields`}</Text>
<Button
color="green"
variant="filled"
onClick={acceptMapping}
>{t`Accept Column Mapping`}</Button>
</Group>
{errorMessage && (
<Alert color="red" title={t`Error`}>
<Text>{errorMessage}</Text>
</Alert>
)}
<SimpleGrid cols={3} spacing="xs">
<Text fw={700}>{t`Database Field`}</Text>
<Text fw={700}>{t`Field Description`}</Text>
<Text fw={700}>{t`Imported Column Name`}</Text>
<Divider />
<Divider />
<Divider />
{session.columnMappings.map((column: any) => {
return [
<Group gap="xs">
<Text fw={column.required ? 700 : undefined}>
{column.label ?? column.field}
</Text>
{column.required && (
<Text c="red" fw={700}>
*
</Text>
)}
</Group>,
<Text size="sm" fs="italic">
{column.description}
</Text>,
<ImporterColumn column={column} options={columnOptions} />
];
})}
</SimpleGrid>
</Stack>
);
}

View File

@ -0,0 +1,133 @@
import { t } from '@lingui/macro';
import {
ActionIcon,
Divider,
Drawer,
Group,
LoadingOverlay,
Paper,
Stack,
Stepper,
Text,
Tooltip
} from '@mantine/core';
import { IconCircleX } from '@tabler/icons-react';
import { ReactNode, useCallback, useMemo, useState } from 'react';
import { ModelType } from '../../enums/ModelType';
import {
ImportSessionStatus,
useImportSession
} from '../../hooks/UseImportSession';
import { StylishText } from '../items/StylishText';
import { StatusRenderer } from '../render/StatusRenderer';
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}
size="xs"
>
<Stepper.Step label={t`Import Data`} />
<Stepper.Step label={t`Map Columns`} />
<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 widget = useMemo(() => {
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 <Text>Complete!</Text>;
default:
return <Text>Unknown status code: {session?.status}</Text>;
}
}, [session.status]);
const title: ReactNode = useMemo(() => {
return (
<Stack gap="xs" style={{ width: '100%' }}>
<Group
gap="xs"
wrap="nowrap"
justify="space-apart"
grow
preventGrowOverflow={false}
>
<StylishText>
{session.sessionData?.statusText ?? t`Importing Data`}
</StylishText>
{StatusRenderer({
status: session.status,
type: ModelType.importsession
})}
<Tooltip label={t`Cancel import session`}>
<ActionIcon color="red" variant="transparent" onClick={onClose}>
<IconCircleX />
</ActionIcon>
</Tooltip>
</Group>
<Divider />
</Stack>
);
}, [session.sessionData]);
return (
<Drawer
position="bottom"
size="80%"
title={title}
opened={opened}
onClose={onClose}
withCloseButton={false}
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>
);
}

View File

@ -0,0 +1,46 @@
import { t } from '@lingui/macro';
import { Center, Container, Loader, Stack, Text } from '@mantine/core';
import { useInterval } from '@mantine/hooks';
import { useEffect } from 'react';
import {
ImportSessionState,
ImportSessionStatus
} from '../../hooks/UseImportSession';
import { StylishText } from '../items/StylishText';
export default function ImporterImportProgress({
session
}: {
session: ImportSessionState;
}) {
// Periodically refresh the import session data
const interval = useInterval(() => {
console.log('refreshing:', session.status);
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>
</>
);
}

View File

@ -66,7 +66,7 @@ export function ActionDropdown({
<ActionIcon
size="lg"
radius="sm"
variant="outline"
variant="transparent"
disabled={disabled}
aria-label={menuName}
>

View File

@ -21,6 +21,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 +34,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

View File

@ -14,3 +14,11 @@ export function RenderProjectCode({
)
);
}
export function RenderImportSession({
instance
}: {
instance: any;
}): ReactNode {
return instance && <RenderInlineModel primary={instance.data_file} />;
}

View File

@ -1,9 +1,12 @@
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, useSuspenseQuery } 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 {
@ -13,7 +16,8 @@ import {
RenderManufacturerPart,
RenderSupplierPart
} from './Company';
import { RenderProjectCode } from './Generic';
import { RenderImportSession, RenderProjectCode } from './Generic';
import { ModelInformationDict } from './ModelType';
import {
RenderPurchaseOrder,
RenderReturnOrder,
@ -75,6 +79,7 @@ const RendererLookup: EnumDictionary<
[ModelType.stockhistory]: RenderStockItem,
[ModelType.supplierpart]: RenderSupplierPart,
[ModelType.user]: RenderUser,
[ModelType.importsession]: RenderImportSession,
[ModelType.reporttemplate]: RenderReportTemplate,
[ModelType.labeltemplate]: RenderLabelTemplate,
[ModelType.pluginconfig]: RenderPlugin
@ -103,6 +108,36 @@ 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>${pk}</Text>;
}
return <RenderInstance model={model} instance={data} />;
}
/**
* Helper function for rendering an inline model in a consistent style
*/

View File

@ -196,6 +196,13 @@ export const ModelInformationDict: ModelDict = {
url_detail: '/user/:pk/',
api_endpoint: ApiEndpoints.user_list
},
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`,

View File

@ -7,6 +7,7 @@ import { useGlobalStatusState } from '../../states/StatusState';
interface StatusCodeInterface {
key: string;
label: string;
name: string;
color: string;
}
@ -41,7 +42,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 +62,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 +118,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;
}

View File

@ -13,7 +13,8 @@ export const statusCodeList: Record<string, ModelType> = {
ReturnOrderStatus: ModelType.returnorder,
SalesOrderStatus: ModelType.salesorder,
StockHistoryCode: ModelType.stockhistory,
StockStatus: ModelType.stockitem
StockStatus: ModelType.stockitem,
DataImportStatusCode: ModelType.importsession
};
/*

View File

@ -46,6 +46,13 @@ export enum ApiEndpoints {
group_list = 'user/group/',
owner_list = 'user/owner/',
// 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/',
notifications_readall = 'notifications/readall/',

View File

@ -21,6 +21,7 @@ export enum ModelType {
salesorder = 'salesorder',
salesordershipment = 'salesordershipment',
returnorder = 'returnorder',
importsession = 'importsession',
address = 'address',
contact = 'contact',
owner = 'owner',

View File

@ -0,0 +1,11 @@
import { ApiFormFieldSet } from '../components/forms/fields/ApiFormField';
export function dataImporterSessionFields(): ApiFormFieldSet {
return {
data_file: {},
model_type: {},
field_detauls: {
hidden: true
}
};
}

View File

@ -0,0 +1,107 @@
import { useCallback, useMemo } from 'react';
import { api } from '../App';
import { ApiEndpoints } from '../enums/ApiEndpoints';
import { apiUrl } from '../states/ApiState';
import { useInstance } from './UseInstance';
/*
* Custom hook for managing the state of a data import session
*/
// TODO: Load these values from the server?
export enum ImportSessionStatus {
INITIAL = 0,
MAPPING = 10,
IMPORTING = 20,
PROCESSING = 30,
COMPLETE = 40
}
export type ImportSessionState = {
sessionId: number;
sessionData: any;
refreshSession: () => void;
sessionQuery: any;
status: ImportSessionStatus;
availableFields: Record<string, any>;
availableColumns: string[];
mappedFields: any[];
columnMappings: any[];
};
export function useImportSession({
sessionId
}: {
sessionId: number;
}): ImportSessionState {
// Query manager for the import session
const {
instance: sessionData,
refreshInstance: refreshSession,
instanceQuery: sessionQuery
} = useInstance({
endpoint: ApiEndpoints.import_session_list,
pk: sessionId,
defaultValue: {}
});
// Current step of the import process
const status: ImportSessionStatus = useMemo(() => {
return sessionData?.status ?? ImportSessionStatus.INITIAL;
}, [sessionData]);
// 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]);
return {
sessionData,
sessionId,
refreshSession,
sessionQuery,
status,
availableFields,
availableColumns,
columnMappings,
mappedFields
};
}

View File

@ -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[]>({

View File

@ -5,6 +5,7 @@ import {
IconCpu,
IconDevicesPc,
IconExclamationCircle,
IconFileUpload,
IconList,
IconListDetails,
IconPackages,
@ -51,6 +52,10 @@ const ErrorReportTable = Loadable(
lazy(() => import('../../../../tables/settings/ErrorTable'))
);
const ImportSesssionTable = Loadable(
lazy(() => import('../../../../tables/settings/ImportSessionTable'))
);
const ProjectCodeTable = Loadable(
lazy(() => import('../../../../tables/settings/ProjectCodeTable'))
);
@ -86,6 +91,12 @@ export default function AdminCenter() {
icon: <IconUsersGroup />,
content: <UserManagementPanel />
},
{
name: 'import',
label: t`Data Import`,
icon: <IconFileUpload />,
content: <ImportSesssionTable />
},
{
name: 'background',
label: t`Background Tasks`,

View File

@ -21,7 +21,6 @@ export function DownloadAction({
const formatOptions = [
{ value: 'csv', label: t`CSV`, icon: <IconFileTypeCsv /> },
{ value: 'tsv', label: t`TSV`, icon: <IconFileText /> },
{ value: 'xls', label: t`Excel (.xls)`, icon: <IconFileSpreadsheet /> },
{ value: 'xlsx', label: t`Excel (.xlsx)`, icon: <IconFileSpreadsheet /> }
];

View File

@ -41,7 +41,7 @@ function FilterItem({
return (
<Paper p="sm" shadow="sm" radius="xs">
<Group justify="space-between" key={flt.name}>
<Group justify="space-between" key={flt.name} wrap="nowrap">
<Stack gap="xs">
<Text size="sm">{flt.label}</Text>
<Text size="xs">{flt.description}</Text>

View File

@ -54,6 +54,7 @@ import { TableFilter } from './Filter';
import { FilterSelectDrawer } from './FilterSelectDrawer';
import { RowAction, RowActions } from './RowActions';
import { TableSearchInput } from './Search';
import { UploadAction } from './UploadAction';
const defaultPageSize: number = 25;
@ -66,6 +67,7 @@ const defaultPageSize: number = 25;
* @param noRecordsText : string - Text to display when no records are found
* @param enableBulkDelete : boolean - Enable bulk deletion of records
* @param enableDownload : boolean - Enable download actions
* @param enableUpload : boolean - Enable upload actions
* @param enableFilters : boolean - Enable filter actions
* @param enableSelection : boolean - Enable row selection
* @param enableSearch : boolean - Enable search actions
@ -73,6 +75,8 @@ const defaultPageSize: number = 25;
* @param enableReports : boolean - Enable printing of reports against selected items
* @param enablePagination : boolean - Enable pagination
* @param enableRefresh : boolean - Enable refresh actions
* @param enableColumnSwitching : boolean - Enable column switching
* @param enableColumnCaching : boolean - Enable caching of column names via API
* @param pageSize : number - Number of records per page
* @param barcodeActions : any[] - List of barcode actions
* @param tableFilters : TableFilter[] - List of custom filters
@ -89,11 +93,14 @@ export type InvenTreeTableProps<T = any> = {
noRecordsText?: string;
enableBulkDelete?: boolean;
enableDownload?: boolean;
enableUpload?: boolean;
enableFilters?: boolean;
enableSelection?: boolean;
enableSearch?: boolean;
enablePagination?: boolean;
enableRefresh?: boolean;
enableColumnSwitching?: boolean;
enableColumnCaching?: boolean;
enableLabels?: boolean;
enableReports?: boolean;
pageSize?: number;
@ -118,6 +125,7 @@ const defaultInvenTreeTableProps: InvenTreeTableProps = {
params: {},
noRecordsText: t`No records found`,
enableDownload: false,
enableUpload: false,
enableLabels: false,
enableReports: false,
enableFilters: true,
@ -167,11 +175,14 @@ export function InvenTreeTable<T = any>({
// Request OPTIONS data from the API, before we load the table
const tableOptionQuery = useQuery({
enabled: true,
queryKey: ['options', url, tableState.tableKey],
queryKey: ['options', url, tableState.tableKey, props.enableColumnCaching],
retry: 3,
refetchOnMount: true,
refetchOnWindowFocus: false,
queryFn: async () => {
if (props.enableColumnCaching == false) {
return null;
}
return api
.options(url, {
params: tableProps.params
@ -204,6 +215,10 @@ export function InvenTreeTable<T = any>({
// Rebuild set of translated column names
useEffect(() => {
if (props.enableColumnCaching == false) {
return;
}
const cacheKey = tableState.tableKey.split('-')[0];
// First check the local cache
@ -217,7 +232,7 @@ export function InvenTreeTable<T = any>({
// Otherwise, fetch the data from the API
tableOptionQuery.refetch();
}, [url, tableState.tableKey, props.params]);
}, [url, tableState.tableKey, props.params, props.enableColumnCaching]);
// Build table properties based on provided props (and default props)
const tableProps: InvenTreeTableProps<T> = useMemo(() => {
@ -229,8 +244,12 @@ export function InvenTreeTable<T = any>({
// Check if any columns are switchable (can be hidden)
const hasSwitchableColumns: boolean = useMemo(() => {
return columns.some((col: TableColumn) => col.switchable ?? true);
}, [columns]);
if (props.enableColumnSwitching == false) {
return false;
} else {
return columns.some((col: TableColumn) => col.switchable ?? true);
}
}, [columns, props.enableColumnSwitching]);
const onSelectedRecordsChange = useCallback(
(records: any[]) => {
@ -527,6 +546,9 @@ export function InvenTreeTable<T = any>({
message: t`Failed to delete records`,
color: 'red'
});
})
.finally(() => {
tableState.clearSelectedRecords();
});
}
});
@ -577,12 +599,7 @@ export function InvenTreeTable<T = any>({
<Stack gap="sm">
<Group justify="apart" grow wrap="nowrap">
<Group justify="left" key="custom-actions" gap={5} wrap="nowrap">
{tableProps.enableDownload && (
<DownloadAction
key="download-action"
downloadCallback={downloadData}
/>
)}
{tableProps.enableUpload && <UploadAction key="upload-action" />}
<PrintingActions
items={tableState.selectedIds}
modelType={tableProps.modelType}
@ -651,6 +668,12 @@ export function InvenTreeTable<T = any>({
</ActionIcon>
</Indicator>
)}
{tableProps.enableDownload && (
<DownloadAction
key="download-action"
downloadCallback={downloadData}
/>
)}
</Group>
</Group>
<Box pos="relative">

View File

@ -0,0 +1,12 @@
import { t } from '@lingui/macro';
import { IconUpload } from '@tabler/icons-react';
import { ActionButton } from '../components/buttons/ActionButton';
export function UploadAction({}) {
return (
<>
<ActionButton icon={<IconUpload />} tooltip={t`Upload Data`} />
</>
);
}

View File

@ -256,7 +256,8 @@ export default function BuildLineTable({ params = {} }: { params?: any }) {
tableFilters: tableFilters,
rowActions: rowActions,
modelType: ModelType.part,
modelField: 'part_detail.pk'
modelField: 'part_detail.pk',
enableDownload: true
}}
/>
);

View File

@ -199,6 +199,7 @@ export function AddressTable({
tableState={table}
columns={columns}
props={{
enableDownload: true,
rowActions: rowActions,
tableActions: tableActions,
params: {

View File

@ -158,6 +158,7 @@ export function CompanyTable({
},
tableFilters: tableFilters,
tableActions: tableActions,
enableDownload: true,
rowActions: rowActions,
onRowClick: (row: any) => {
if (row.pk) {

View File

@ -143,6 +143,7 @@ export function ContactTable({
tableState={table}
columns={columns}
props={{
enableDownload: true,
rowActions: rowActions,
tableActions: tableActions,
params: {

View File

@ -147,7 +147,8 @@ export default function PartCategoryTemplateTable({}: {}) {
props={{
rowActions: rowActions,
tableFilters: tableFilters,
tableActions: tableActions
tableActions: tableActions,
enableDownload: true
}}
/>
</>

View File

@ -184,6 +184,7 @@ export function PartParameterTable({ partId }: { partId: any }) {
columns={tableColumns}
props={{
rowActions: rowActions,
enableDownload: true,
tableActions: tableActions,
tableFilters: [
{

View File

@ -157,7 +157,8 @@ export default function PartParameterTemplateTable() {
props={{
rowActions: rowActions,
tableFilters: tableFilters,
tableActions: tableActions
tableActions: tableActions,
enableDownload: true
}}
/>
</>

View File

@ -144,6 +144,7 @@ export function ManufacturerPartTable({ params }: { params: any }): ReactNode {
part_detail: true,
manufacturer_detail: true
},
enableDownload: true,
rowActions: rowActions,
tableActions: tableActions,
modelType: ModelType.manufacturerpart

View File

@ -259,6 +259,7 @@ export function SupplierPartTable({ params }: { params: any }): ReactNode {
manufacturer_detail: true
},
rowActions: rowActions,
enableDownload: true,
tableActions: tableActions,
tableFilters: tableFilters,
modelType: ModelType.supplierpart

View File

@ -116,7 +116,8 @@ export default function CustomUnitsTable() {
columns={columns}
props={{
rowActions: rowActions,
tableActions: tableActions
tableActions: tableActions,
enableDownload: true
}}
/>
</>

View File

@ -0,0 +1,180 @@
import { t } from '@lingui/macro';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import ImporterDrawer from '../../components/importer/ImporterDrawer';
import { AttachmentLink } from '../../components/items/AttachmentLink';
import { ProgressBar } from '../../components/items/ProgressBar';
import { RenderUser } from '../../components/render/User';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { dataImporterSessionFields } from '../../forms/ImporterForms';
import { useFilters, useUserFilters } from '../../hooks/UseFilter';
import {
useCreateApiFormModal,
useDeleteApiFormModal
} from '../../hooks/UseForm';
import { useTable } from '../../hooks/UseTable';
import { apiUrl } from '../../states/ApiState';
import { useUserState } from '../../states/UserState';
import { TableColumn } from '../Column';
import { DateColumn, StatusColumn } from '../ColumnRenderers';
import { StatusFilterOptions, TableFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { RowAction, RowDeleteAction } from '../RowActions';
export default function ImportSesssionTable() {
const table = useTable('importsession');
const user = useUserState();
const [opened, setOpened] = useState<boolean>(false);
const [selectedSession, setSelectedSession] = useState<number | undefined>(
undefined
);
const deleteSession = useDeleteApiFormModal({
url: ApiEndpoints.import_session_list,
pk: selectedSession,
title: t`Delete Import Session`,
table: table
});
const newImportSession = useCreateApiFormModal({
url: ApiEndpoints.import_session_list,
title: t`Create Import Session`,
fields: dataImporterSessionFields(),
onFormSuccess: (response: any) => {
setSelectedSession(response.pk);
setOpened(true);
table.refreshTable();
}
});
const columns: TableColumn[] = useMemo(() => {
return [
{
accessor: 'model_type',
sortable: true
},
StatusColumn({ model: ModelType.importsession }),
{
accessor: 'data_file',
render: (record: any) => (
<AttachmentLink attachment={record.data_file} />
),
sortable: false
},
DateColumn({
accessor: 'timestamp',
title: t`Uploaded`
}),
{
accessor: 'user',
sortable: false,
render: (record: any) => RenderUser({ instance: record.user_detail })
},
{
sortable: false,
accessor: 'row_count',
title: t`Imported Rows`,
render: (record: any) => (
<ProgressBar
progressLabel={true}
value={record.completed_row_count}
maximum={record.row_count}
/>
)
}
];
}, []);
const userFilter = useUserFilters();
const modelTypeFilters = useFilters({
url: apiUrl(ApiEndpoints.import_session_list),
method: 'OPTIONS',
accessor: 'data.actions.POST.model_type.choices',
transform: (item: any) => {
return {
value: item.value,
label: item.display_name
};
}
});
const tableFilters: TableFilter[] = useMemo(() => {
return [
{
name: 'model_type',
label: t`Model Type`,
description: t`Filter by target model type`,
choices: modelTypeFilters.choices
},
{
name: 'status',
label: t`Status`,
description: t`Filter by import session status`,
choiceFunction: StatusFilterOptions(ModelType.importsession)
},
{
name: 'user',
label: t`User`,
description: t`Filter by user`,
choices: userFilter.choices
}
];
}, [modelTypeFilters.choices, userFilter.choices]);
const tableActions = useMemo(() => {
return [
<AddItemButton
tooltip={t`Create Import Session`}
onClick={() => newImportSession.open()}
/>
];
}, []);
const rowActions = useCallback((record: any): RowAction[] => {
return [
RowDeleteAction({
onClick: () => {
setSelectedSession(record.pk);
deleteSession.open();
}
})
];
}, []);
return (
<>
{newImportSession.modal}
{deleteSession.modal}
<InvenTreeTable
url={apiUrl(ApiEndpoints.import_session_list)}
tableState={table}
columns={columns}
props={{
rowActions: rowActions,
tableActions: tableActions,
tableFilters: tableFilters,
enableBulkDelete: true,
enableSelection: true,
onRowClick: (record: any) => {
setSelectedSession(record.pk);
setOpened(true);
}
}}
/>
<ImporterDrawer
sessionId={selectedSession ?? -1}
opened={selectedSession !== undefined && opened}
onClose={() => {
setSelectedSession(undefined);
setOpened(false);
table.refreshTable();
}}
/>
</>
);
}

View File

@ -86,16 +86,12 @@ export default function ProjectCodeTable() {
);
const tableActions = useMemo(() => {
let actions = [];
actions.push(
return [
<AddItemButton
onClick={() => newProjectCode.open()}
tooltip={t`Add project code`}
/>
);
return actions;
];
}, []);
return (
@ -109,7 +105,8 @@ export default function ProjectCodeTable() {
columns={columns}
props={{
rowActions: rowActions,
tableActions: tableActions
tableActions: tableActions,
enableDownload: true
}}
/>
</>

View File

@ -4,7 +4,7 @@ import { ReactNode, useMemo } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { ActionDropdown } from '../../components/items/ActionDropdown';
import { formatCurrency } from '../../defaults/formatters';
import { formatCurrency, formatPriceRange } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { ModelType } from '../../enums/ModelType';
import { UserRoles } from '../../enums/Roles';
@ -237,10 +237,32 @@ function stockItemTableColumns(): TableColumn[] {
formatCurrency(record.purchase_price, {
currency: record.purchase_price_currency
})
},
{
accessor: 'packaging',
sortable: true
},
{
accessor: 'stock_value',
title: t`Stock Value`,
sortable: false,
render: (record: any) => {
let min_price =
record.purchase_price || record.part_detail?.pricing_min;
let max_price =
record.purchase_price || record.part_detail?.pricing_max;
let currency = record.purchase_price_currency || undefined;
return formatPriceRange(min_price, max_price, {
currency: currency,
multiplier: record.quantity
});
}
},
{
accessor: 'notes',
sortable: false
}
// TODO: stock value
// TODO: packaging
// TODO: notes
];
}

View File

@ -213,7 +213,8 @@ export function StockTrackingTable({ itemId }: { itemId: number }) {
params: {
item: itemId,
user_detail: true
}
},
enableDownload: true
}}
/>
);