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:
@ -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}
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -65,6 +65,7 @@ export function ChoiceField({
|
||||
disabled={definition.disabled}
|
||||
leftSection={definition.icon}
|
||||
comboboxProps={{ withinPortal: true }}
|
||||
searchable
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
397
src/frontend/src/components/importer/ImportDataSelector.tsx
Normal file
397
src/frontend/src/components/importer/ImportDataSelector.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
144
src/frontend/src/components/importer/ImporterColumnSelector.tsx
Normal file
144
src/frontend/src/components/importer/ImporterColumnSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
133
src/frontend/src/components/importer/ImporterDrawer.tsx
Normal file
133
src/frontend/src/components/importer/ImporterDrawer.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
@ -66,7 +66,7 @@ export function ActionDropdown({
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
radius="sm"
|
||||
variant="outline"
|
||||
variant="transparent"
|
||||
disabled={disabled}
|
||||
aria-label={menuName}
|
||||
>
|
||||
|
@ -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
|
||||
|
@ -14,3 +14,11 @@ export function RenderProjectCode({
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function RenderImportSession({
|
||||
instance
|
||||
}: {
|
||||
instance: any;
|
||||
}): ReactNode {
|
||||
return instance && <RenderInlineModel primary={instance.data_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
|
||||
*/
|
||||
|
@ -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`,
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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
|
||||
};
|
||||
|
||||
/*
|
||||
|
@ -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/',
|
||||
|
@ -21,6 +21,7 @@ export enum ModelType {
|
||||
salesorder = 'salesorder',
|
||||
salesordershipment = 'salesordershipment',
|
||||
returnorder = 'returnorder',
|
||||
importsession = 'importsession',
|
||||
address = 'address',
|
||||
contact = 'contact',
|
||||
owner = 'owner',
|
||||
|
11
src/frontend/src/forms/ImporterForms.tsx
Normal file
11
src/frontend/src/forms/ImporterForms.tsx
Normal 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
|
||||
}
|
||||
};
|
||||
}
|
107
src/frontend/src/hooks/UseImportSession.tsx
Normal file
107
src/frontend/src/hooks/UseImportSession.tsx
Normal 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
|
||||
};
|
||||
}
|
@ -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[]>({
|
||||
|
@ -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`,
|
||||
|
@ -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 /> }
|
||||
];
|
||||
|
||||
|
@ -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>
|
||||
|
@ -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">
|
||||
|
12
src/frontend/src/tables/UploadAction.tsx
Normal file
12
src/frontend/src/tables/UploadAction.tsx
Normal 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`} />
|
||||
</>
|
||||
);
|
||||
}
|
@ -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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -199,6 +199,7 @@ export function AddressTable({
|
||||
tableState={table}
|
||||
columns={columns}
|
||||
props={{
|
||||
enableDownload: true,
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions,
|
||||
params: {
|
||||
|
@ -158,6 +158,7 @@ export function CompanyTable({
|
||||
},
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions,
|
||||
enableDownload: true,
|
||||
rowActions: rowActions,
|
||||
onRowClick: (row: any) => {
|
||||
if (row.pk) {
|
||||
|
@ -143,6 +143,7 @@ export function ContactTable({
|
||||
tableState={table}
|
||||
columns={columns}
|
||||
props={{
|
||||
enableDownload: true,
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions,
|
||||
params: {
|
||||
|
@ -147,7 +147,8 @@ export default function PartCategoryTemplateTable({}: {}) {
|
||||
props={{
|
||||
rowActions: rowActions,
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions
|
||||
tableActions: tableActions,
|
||||
enableDownload: true
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -184,6 +184,7 @@ export function PartParameterTable({ partId }: { partId: any }) {
|
||||
columns={tableColumns}
|
||||
props={{
|
||||
rowActions: rowActions,
|
||||
enableDownload: true,
|
||||
tableActions: tableActions,
|
||||
tableFilters: [
|
||||
{
|
||||
|
@ -157,7 +157,8 @@ export default function PartParameterTemplateTable() {
|
||||
props={{
|
||||
rowActions: rowActions,
|
||||
tableFilters: tableFilters,
|
||||
tableActions: tableActions
|
||||
tableActions: tableActions,
|
||||
enableDownload: true
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -116,7 +116,8 @@ export default function CustomUnitsTable() {
|
||||
columns={columns}
|
||||
props={{
|
||||
rowActions: rowActions,
|
||||
tableActions: tableActions
|
||||
tableActions: tableActions,
|
||||
enableDownload: true
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
180
src/frontend/src/tables/settings/ImportSessionTable.tsx
Normal file
180
src/frontend/src/tables/settings/ImportSessionTable.tsx
Normal 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();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
@ -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
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
@ -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
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -213,7 +213,8 @@ export function StockTrackingTable({ itemId }: { itemId: number }) {
|
||||
params: {
|
||||
item: itemId,
|
||||
user_detail: true
|
||||
}
|
||||
},
|
||||
enableDownload: true
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
Reference in New Issue
Block a user