Merge commit '3393ef4e65267f4f8c5a5a87798553dbe0160a39' into block-notes

This commit is contained in:
Oliver Walters
2026-06-05 12:57:45 +00:00
87 changed files with 1730 additions and 350 deletions
+27
View File
@@ -0,0 +1,27 @@
import { ActionIcon, Badge, Group, Paper } from '@mantine/core';
import { IconTag } from '@tabler/icons-react';
export default function TagsList({
tags
}: Readonly<{
tags: string[];
}>) {
if (!tags || tags.length === 0) {
return null;
}
return (
<Paper p='xs' shadow='xs' withBorder>
<Group gap='xs'>
<ActionIcon size='sm' variant='transparent'>
<IconTag />
</ActionIcon>
{tags.map((tag: string) => (
<Badge key={tag} variant='outline' size='sm'>
{tag}
</Badge>
))}
</Group>
</Paper>
);
}
+1
View File
@@ -260,6 +260,7 @@ export enum ApiEndpoints {
config_list = 'admin/config/',
parameter_list = 'parameter/',
parameter_template_list = 'parameter/template/',
tag_list = 'tag/',
// Internal system things
system_internal_trace_end = 'system-internal/observability/end'
+8 -2
View File
@@ -118,8 +118,8 @@ export const ModelInformationDict: ModelDict = {
icon: 'history'
},
build: {
label: () => t`Build`,
label_multiple: () => t`Builds`,
label: () => t`Build Order`,
label_multiple: () => t`Build Orders`,
url_overview: '/manufacturing/index/buildorders/',
url_detail: '/manufacturing/build-order/:pk/',
api_endpoint: ApiEndpoints.build_order_list,
@@ -319,5 +319,11 @@ export const ModelInformationDict: ModelDict = {
url_overview: '/settings/admin/errors',
url_detail: '/settings/admin/errors/:pk/',
icon: 'exclamation'
},
tag: {
label: () => t`Tag`,
label_multiple: () => t`Tags`,
api_endpoint: ApiEndpoints.tag_list,
icon: 'tag'
}
};
+2 -1
View File
@@ -38,7 +38,8 @@ export enum ModelType {
contenttype = 'contenttype',
selectionlist = 'selectionlist',
selectionentry = 'selectionentry',
error = 'error'
error = 'error',
tag = 'tag'
}
export enum PluginPanelKey {
+54 -3
View File
@@ -1,6 +1,10 @@
import { useLocalStorage } from '@mantine/hooks';
import { useCallback, useEffect, useMemo } from 'react';
import type { FilterSetState, TableFilter } from '../types/Filters';
import type {
FilterSetState,
NamedFilterSet,
TableFilter
} from '../types/Filters';
export default function useFilterSet(
filterKey: string,
@@ -16,6 +20,16 @@ export default function useFilterSet(
getInitialValueInEffect: false
});
// Named filter set snapshots (saved to local storage, separate key)
const [storedNamedSets, setStoredNamedSets] = useLocalStorage<
NamedFilterSet[]
>({
key: `inventree-filtersets-${filterKey}`,
defaultValue: [],
sync: false,
getInitialValueInEffect: false
});
useEffect(() => {
if (storedFilters == null) {
setStoredFilters(initialFilters || []);
@@ -26,7 +40,6 @@ export default function useFilterSet(
return storedFilters ?? initialFilters ?? [];
}, [storedFilters, initialFilters]);
// Callback to clear all active filters from the table
const clearActiveFilters = useCallback(() => {
setStoredFilters([]);
}, []);
@@ -38,10 +51,48 @@ export default function useFilterSet(
[setStoredFilters]
);
const saveFilterSet = useCallback(
(name: string) => {
const snapshot = activeFilters.map(
({ name: n, value, displayValue }) => ({
name: n,
value,
displayValue
})
);
setStoredNamedSets((prev) => {
const without = (prev ?? []).filter((s) => s.name !== name);
return [...without, { name, filters: snapshot }];
});
},
[activeFilters, setStoredNamedSets]
);
const loadFilterSet = useCallback(
(name: string) => {
const saved = (storedNamedSets ?? []).find((s) => s.name === name);
if (saved) {
setStoredFilters(saved.filters as TableFilter[]);
}
},
[storedNamedSets, setStoredFilters]
);
const deleteFilterSet = useCallback(
(name: string) => {
setStoredNamedSets((prev) => (prev ?? []).filter((s) => s.name !== name));
},
[setStoredNamedSets]
);
return {
filterKey,
activeFilters,
setActiveFilters,
clearActiveFilters
clearActiveFilters,
savedFilterSets: storedNamedSets ?? [],
saveFilterSet,
loadFilterSet,
deleteFilterSet
};
}
+1
View File
@@ -110,6 +110,7 @@ export { ProgressBar } from './components/ProgressBar';
export { PassFailButton, YesNoButton } from './components/YesNoButton';
export { SearchInput } from './components/SearchInput';
export { TableColumnSelect } from './components/TableColumnSelect';
export { default as TagsList } from './components/TagsList';
export { default as InvenTreeTable } from './components/InvenTreeTable';
export {
RowViewAction,
+19
View File
@@ -41,6 +41,7 @@ export type TableFilter = {
name: string;
label?: string;
description?: string;
placeholder?: string;
type?: TableFilterType;
choices?: TableFilterChoice[];
choiceFunction?: () => TableFilterChoice[];
@@ -52,6 +53,16 @@ export type TableFilter = {
apiFilter?: Record<string, any>;
model?: ModelType;
modelRenderer?: (instance: any) => string;
transform?: (item: any) => TableFilterChoice;
multi?: boolean;
};
/*
* A named snapshot of a set of active filters, saved to local storage.
*/
export type NamedFilterSet = {
name: string;
filters: Pick<TableFilter, 'name' | 'value' | 'displayValue'>[];
};
/*
@@ -62,10 +73,18 @@ export type TableFilter = {
* activeFilters: An array of active filters
* setActiveFilters: A function to set the active filters
* clearActiveFilters: A function to clear all active filters
* savedFilterSets: Named filter set snapshots persisted to local storage
* saveFilterSet: Save the current active filters under a given name
* loadFilterSet: Replace active filters with a previously saved named set
* deleteFilterSet: Remove a saved named filter set by name
*/
export type FilterSetState = {
filterKey: string;
activeFilters: TableFilter[];
setActiveFilters: (filters: TableFilter[]) => void;
clearActiveFilters: () => void;
savedFilterSets: NamedFilterSet[];
saveFilterSet: (name: string) => void;
loadFilterSet: (name: string) => void;
deleteFilterSet: (name: string) => void;
};
+2 -1
View File
@@ -99,7 +99,8 @@ export type ApiFormFieldType = {
| 'file upload'
| 'nested object'
| 'dependent field'
| 'table';
| 'table'
| 'tags';
api_url?: string;
pk_field?: string;
model?: ModelType;
@@ -32,7 +32,8 @@ import {
IconChevronLeft,
IconChevronRight,
IconDownload,
IconFilter
IconFilter,
IconRefresh
} from '@tabler/icons-react';
import {
type ReactNode,
@@ -55,6 +56,7 @@ export interface InvenTreeCalendarProps extends CalendarOptions {
enableDownload?: boolean;
enableFilters?: boolean;
enableSearch?: boolean;
enableRefresh?: boolean;
eventTooltipContent?: (event: EventContentArg) => ReactNode;
filters?: TableFilter[];
isLoading?: boolean;
@@ -65,6 +67,7 @@ export default function Calendar({
enableDownload,
enableFilters = false,
enableSearch,
enableRefresh = true,
eventTooltipContent,
isLoading,
filters,
@@ -214,6 +217,18 @@ export default function Calendar({
{enableSearch && (
<SearchInput searchCallback={state.setSearchTerm} />
)}
{enableRefresh && (
<ActionIcon
variant='transparent'
aria-label='calendar-refresh'
disabled={isLoading}
onClick={() => state.query.refetch()}
>
<Tooltip label={t`Refresh calendar`} position='top-end'>
<IconRefresh />
</Tooltip>
</ActionIcon>
)}
{enableFilters && filters && filters.length > 0 && (
<Indicator
size='xs'
@@ -30,7 +30,7 @@ import {
ProjectCodeFilter,
ResponsibleFilter
} from '../../tables/Filter';
import { StatusRenderer } from '../render/StatusRenderer';
import { StatusRenderer, getStatusColor } from '../render/StatusRenderer';
import Calendar from './Calendar';
/**
@@ -107,6 +107,8 @@ export default function OrderCalendar({
order.start_date || order.issue_date || order.creation_date || today;
const end: string = order.target_date || start;
const statusColor = getStatusColor(model, order.status);
return {
order: order,
id: order.pk,
@@ -115,7 +117,9 @@ export default function OrderCalendar({
start: start,
end: end,
startEditable: canEdit,
durationEditable: canEdit
durationEditable: canEdit,
backgroundColor: statusColor,
borderColor: statusColor
};
}) ?? []
);
@@ -207,6 +211,7 @@ export default function OrderCalendar({
<Calendar
enableDownload
enableFilters
enableRefresh
enableSearch
events={events}
state={calendarState}
@@ -17,6 +17,7 @@ import { NestedObjectField } from './NestedObjectField';
import NumberField from './NumberField';
import { RelatedModelField } from './RelatedModelField';
import { TableField } from './TableField';
import TagsField from './TagsField';
import TextField from './TextField';
/**
@@ -249,6 +250,10 @@ export function ApiFormField({
control={controller}
/>
);
case 'tags':
return (
<TagsField controller={controller} definition={fieldDefinition} />
);
default:
return (
<Alert color='red' title={t`Error`}>
@@ -34,13 +34,13 @@ export function BooleanField({
// Coerce the value to a (stringified) boolean value
const booleanValue: boolean = useMemo(() => {
return isTrue(value);
return isTrue(value ?? definition.default ?? false);
}, [value]);
return (
<Switch
{...definition}
defaultValue={definition.default ?? false}
defaultValue={undefined}
checked={booleanValue}
id={fieldId}
aria-label={`boolean-field-${fieldName}`}
@@ -0,0 +1,81 @@
import { TagsInput } from '@mantine/core';
import { useDebouncedValue } from '@mantine/hooks';
import { useQuery } from '@tanstack/react-query';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { FieldValues, UseControllerReturn } from 'react-hook-form';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { apiUrl } from '@lib/functions/Api';
import type { ApiFormFieldType } from '@lib/types/Forms';
import { api } from '../../../App';
export default function TagsField({
controller,
definition
}: Readonly<{
controller: UseControllerReturn<FieldValues, any>;
definition: ApiFormFieldType;
}>) {
const {
field,
fieldState: { error }
} = controller;
const [tags, setTags] = useState<string[]>(field.value ?? []);
const [searchValue, setSearchValue] = useState('');
const [debouncedSearch] = useDebouncedValue(searchValue, 250);
// Sync inbound value changes (e.g. when initial data is loaded)
useEffect(() => {
setTags(field.value ?? []);
}, [field.value]);
const onChange = useCallback(
(value: string[]) => {
setTags(value);
field.onChange(value);
definition.onValueChange?.(value);
},
[field, definition]
);
const tagQuery = useQuery({
queryKey: ['tags-autocomplete', debouncedSearch],
queryFn: () =>
api
.get(apiUrl(ApiEndpoints.tag_list), {
params: { search: debouncedSearch, limit: 20 }
})
.then((r) => r.data)
});
const suggestions: string[] = useMemo(() => {
const results: any[] = tagQuery.data?.results ?? tagQuery.data ?? [];
return results.map((tag: any) => String(tag.name));
}, [tagQuery.data]);
const reducedDefinition: any = useMemo(() => {
return {
...definition,
allow_null: undefined,
allow_blank: undefined
};
}, [definition]);
return (
<TagsInput
{...reducedDefinition}
ref={field.ref}
placeholder={definition.placeholder}
aria-label={`tags-field-${field.name}`}
value={tags}
onChange={onChange}
data={suggestions}
searchValue={searchValue}
onSearchChange={setSearchValue}
error={definition.error ?? error?.message}
radius='sm'
splitChars={[',', '\t', '\n', ';', ':', '.', '-']}
/>
);
}
@@ -56,6 +56,12 @@ export function RenderError({
return instance && <RenderInlineModel primary={instance.name} />;
}
export function RenderTag({
instance
}: Readonly<InstanceRenderInterface>): ReactNode {
return instance && <RenderInlineModel primary={instance.name} />;
}
export function RenderImportSession({
instance
}: {
@@ -55,7 +55,8 @@ import {
RenderParameterTemplate,
RenderProjectCode,
RenderSelectionEntry,
RenderSelectionList
RenderSelectionList,
RenderTag
} from './Generic';
import {
RenderPurchaseOrder,
@@ -116,7 +117,8 @@ export const RendererLookup: ModelRendererDict = {
[ModelType.contenttype]: RenderContentType,
[ModelType.selectionlist]: RenderSelectionList,
[ModelType.selectionentry]: RenderSelectionEntry,
[ModelType.error]: RenderError
[ModelType.error]: RenderError,
[ModelType.tag]: RenderTag
};
/**
@@ -202,6 +202,31 @@ export const StatusRenderer = ({
return renderStatusLabel(status, statusCodes, options, fallbackStatus);
};
/*
* Return a CSS color string (Mantine CSS variable) for a given status code.
* Used to set event background colors in calendar views.
*/
export function getStatusColor(
type: ModelType | string,
status: string | number
): string {
const statusCodes = getStatusCodes(type);
const fallback = `var(--mantine-color-${statusColorMap['default']}-6)`;
if (!statusCodes) return fallback;
for (const name in statusCodes.values) {
const entry: StatusCodeInterface = statusCodes.values[name];
if (entry?.key == status) {
const mantineColor =
statusColorMap[entry.color] ?? statusColorMap['default'];
return `var(--mantine-color-${mantineColor}-6)`;
}
}
return fallback;
}
/*
* Render the status badge in a table
*/
+34 -17
View File
@@ -36,6 +36,7 @@ import {
} from '../hooks/UseGenerator';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { RenderPartColumn } from '../tables/ColumnRenderers';
import { TagsField } from './CommonFields';
/**
* Field set for BuildOrder forms
@@ -124,6 +125,7 @@ export function useBuildOrderFields({
},
value: destination
},
tags: TagsField({}),
link: {
icon: <IconLink />
},
@@ -341,16 +343,21 @@ export function useCompleteBuildOutputsForm({
);
}, [location, build.destination, build.part_detail]);
// Memoize the outputs once to avoid re-rendering issues
const buildOutputs = useMemo(() => {
return outputs.map((output: any) => {
return {
output: output.pk,
quantity: output.quantity
};
});
}, [outputs]);
const buildOutputCompleteFields: ApiFormFieldSet = useMemo(() => {
return {
outputs: {
field_type: 'table',
value: outputs.map((output: any) => {
return {
output: output.pk,
quantity: output.quantity
};
}),
value: buildOutputs,
modelRenderer: (row: TableFieldRowProps) => {
const record = outputs.find((output) => output.pk == row.item.output);
return (
@@ -418,16 +425,21 @@ export function useScrapBuildOutputsForm({
);
}, [location, build.destination, build.part_detail]);
// Memoize the outputs once to avoid re-rendering issues
const buildOutputs = useMemo(() => {
return outputs.map((output: any) => {
return {
output: output.pk,
quantity: output.quantity
};
});
}, [outputs]);
const buildOutputScrapFields: ApiFormFieldSet = useMemo(() => {
return {
outputs: {
field_type: 'table',
value: outputs.map((output: any) => {
return {
output: output.pk,
quantity: output.quantity
};
}),
value: buildOutputs,
modelRenderer: (row: TableFieldRowProps) => {
const record = outputs.find((output) => output.pk == row.item.output);
return (
@@ -484,15 +496,20 @@ export function useCancelBuildOutputsForm({
outputs: any[];
onFormSuccess: (response: any) => void;
}) {
// Memoize the outputs once to avoid re-rendering issues
const buildOutputs = useMemo(() => {
return outputs.map((output: any) => {
return {
output: output.pk
};
});
}, [outputs]);
const buildOutputCancelFields: ApiFormFieldSet = useMemo(() => {
return {
outputs: {
field_type: 'table',
value: outputs.map((output: any) => {
return {
output: output.pk
};
}),
value: buildOutputs,
modelRenderer: (row: TableFieldRowProps) => {
const record = outputs.find((output) => output.pk == row.item.output);
return (
+19
View File
@@ -0,0 +1,19 @@
import type { ApiFormFieldType } from '@lib/types/Forms';
import { t } from '@lingui/core/macro';
export function TagsField({
label,
description,
placeholder
}: Readonly<{
label?: string;
description?: string;
placeholder?: string;
}>): ApiFormFieldType {
return {
field_type: 'tags',
label: label ?? t`Tags`,
description: description ?? t`Tags for this item`,
placeholder: placeholder ?? t`Select tags`
};
}
+4
View File
@@ -13,6 +13,7 @@ import {
IconPhone
} from '@tabler/icons-react';
import { useMemo, useState } from 'react';
import { TagsField } from './CommonFields';
/**
* Field set for SupplierPart instance
@@ -82,6 +83,7 @@ export function useSupplierPartFields({
icon: <IconHash />
},
description: {},
tags: TagsField({}),
link: {
icon: <IconLink />
},
@@ -117,6 +119,7 @@ export function useManufacturerPartFields() {
},
MPN: {},
description: {},
tags: TagsField({}),
link: {}
};
@@ -143,6 +146,7 @@ export function companyFields(): ApiFormFieldSet {
email: {
icon: <IconAt />
},
tags: TagsField({}),
tax_id: {},
is_supplier: {},
is_manufacturer: {},
+2
View File
@@ -3,6 +3,7 @@ import { t } from '@lingui/core/macro';
import { IconBuildingStore, IconCopy, IconPackages } from '@tabler/icons-react';
import { useEffect, useMemo, useState } from 'react';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { TagsField } from './CommonFields';
/**
* Construct a set of fields for creating / editing a Part instance
@@ -54,6 +55,7 @@ export function usePartFields({
}
},
keywords: {},
tags: TagsField({}),
units: {},
link: {},
default_location: {
@@ -57,6 +57,7 @@ import {
useSerialNumberGenerator
} from '../hooks/UseGenerator';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { TagsField } from './CommonFields';
/*
* Construct a set of fields for creating / editing a PurchaseOrderLineItem instance
*/
@@ -287,6 +288,7 @@ export function usePurchaseOrderFields({
structural: false
}
},
tags: TagsField({}),
link: {},
contact: {
icon: <IconUser />,
@@ -23,6 +23,7 @@ import { Thumbnail } from '../components/images/Thumbnail';
import { useCreateApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { StatusFilterOptions } from '../tables/Filter';
import { TagsField } from './CommonFields';
export function useReturnOrderFields({
duplicateOrderId
@@ -52,6 +53,7 @@ export function useReturnOrderFields({
icon: <IconCalendar />
},
link: {},
tags: TagsField({}),
contact: {
icon: <IconUser />,
adjustFilters: (value: ApiFormAdjustFilterType) => {
@@ -31,6 +31,7 @@ import { useCreateApiFormModal, useEditApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { useUserState } from '../states/UserState';
import { RenderPartColumn } from '../tables/ColumnRenderers';
import { TagsField } from './CommonFields';
export function useSalesOrderFields({
duplicateOrderId
@@ -64,6 +65,7 @@ export function useSalesOrderFields({
target_date: {
icon: <IconCalendar />
},
tags: TagsField({}),
link: {},
contact: {
icon: <IconUser />,
@@ -537,6 +539,7 @@ export function useSalesOrderShipmentFields({
},
tracking_number: {},
invoice_number: {},
tags: TagsField({}),
link: {}
};
}, [customerId, pending]);
+2
View File
@@ -61,6 +61,7 @@ import {
import useStatusCodes from '../hooks/UseStatusCodes';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { StatusFilterOptions } from '../tables/Filter';
import { TagsField } from './CommonFields';
/**
* Construct a set of fields for creating / editing a StockItem instance
@@ -272,6 +273,7 @@ export function useStockFields({
packaging: {
icon: <IconPackage />
},
tags: TagsField({}),
link: {
icon: <IconLink />
},
@@ -10,6 +10,7 @@ import type { TableFieldRowProps } from '../components/forms/fields/TableField';
import { useCreateApiFormModal } from '../hooks/UseForm';
import { useGlobalSettingsState } from '../states/SettingsStates';
import { RenderPartColumn } from '../tables/ColumnRenderers';
import { TagsField } from './CommonFields';
export function useTransferOrderFields({
duplicateOrderId
@@ -36,6 +37,7 @@ export function useTransferOrderFields({
}
},
consume: {},
tags: TagsField({}),
link: {},
responsible: {
filters: {
+18 -12
View File
@@ -21,6 +21,7 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import { TagsList } from '@lib/index';
import type { ApiFormFieldSet } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
@@ -228,7 +229,8 @@ export default function BuildDetail() {
endpoint: ApiEndpoints.build_order_list,
pk: id,
params: {
part_detail: true
part_detail: true,
tags: true
},
refetchOnMount: true
});
@@ -438,17 +440,20 @@ export default function BuildDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list}
src={build.part_detail?.image ?? build.part_detail?.thumbnail}
pk={build.part}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list}
src={build.part_detail?.image ?? build.part_detail?.thumbnail}
pk={build.part}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={build.tags} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
@@ -612,6 +617,7 @@ export default function BuildDetail() {
title: t`Edit Build Order`,
modalId: 'edit-build-order',
fields: editBuildOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});
@@ -18,6 +18,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import { PrintingActions } from '../../components/buttons/PrintingActions';
@@ -78,7 +79,9 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
} = useInstance({
endpoint: ApiEndpoints.company_list,
pk: id,
params: {},
params: {
tags: true
},
refetchOnMount: true
});
@@ -153,23 +156,26 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={apiUrl(ApiEndpoints.company_list, company.pk)}
src={company.image}
pk={company.pk}
refresh={refreshInstance}
imageActions={{
uploadFile: true,
downloadImage: true,
deleteFile: true
}}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable item={company} fields={tl} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={apiUrl(ApiEndpoints.company_list, company.pk)}
src={company.image}
pk={company.pk}
refresh={refreshInstance}
imageActions={{
uploadFile: true,
downloadImage: true,
deleteFile: true
}}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable item={company} fields={tl} />
</Grid.Col>
</Grid>
<TagsList tags={company.tags} />
</Stack>
<DetailsTable item={company} fields={tr} />
</ItemDetailsGrid>
);
@@ -288,6 +294,7 @@ export default function CompanyDetail(props: Readonly<CompanyDetailProps>) {
pk: company?.pk,
title: t`Edit Company`,
fields: companyFields(),
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});
@@ -8,6 +8,7 @@ import {
import { useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@@ -59,7 +60,8 @@ export default function ManufacturerPartDetail() {
hasPrimaryKey: true,
params: {
part_detail: true,
manufacturer_detail: true
manufacturer_detail: true,
tags: true
}
});
@@ -133,20 +135,23 @@ export default function ManufacturerPartDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
src={manufacturerPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
manufacturerPart?.part_detail?.pk
)}
pk={manufacturerPart?.part_detail?.pk}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
src={manufacturerPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
manufacturerPart?.part_detail?.pk
)}
pk={manufacturerPart?.part_detail?.pk}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={manufacturerPart.tags} />
</Stack>
<DetailsTable title={t`Manufacturer Details`} fields={tr} item={data} />
</ItemDetailsGrid>
);
@@ -211,6 +216,7 @@ export default function ManufacturerPartDetail() {
pk: manufacturerPart?.pk,
title: t`Edit Manufacturer Part`,
fields: editManufacturerPartFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});
@@ -9,6 +9,7 @@ import {
import { type ReactNode, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@@ -67,7 +68,8 @@ export default function SupplierPartDetail() {
params: {
part_detail: true,
supplier_detail: true,
manufacturer_detail: true
manufacturer_detail: true,
tags: true
}
});
@@ -221,20 +223,23 @@ export default function SupplierPartDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
src={supplierPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
supplierPart?.part_detail?.pk
)}
pk={supplierPart?.part_detail?.pk}
/>
<Grid.Col span={8}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
src={supplierPart?.part_detail?.image}
apiPath={apiUrl(
ApiEndpoints.part_list,
supplierPart?.part_detail?.pk
)}
pk={supplierPart?.part_detail?.pk}
/>
<Grid.Col span={8}>
<DetailsTable title={t`Part Details`} fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={supplierPart.tags} />
</Stack>
<DetailsTable title={t`Supplier`} fields={bl} item={data} />
<DetailsTable title={t`Packaging`} fields={br} item={data} />
<DetailsTable title={t`Availability`} fields={tr} item={data} />
@@ -339,6 +344,7 @@ export default function SupplierPartDetail() {
pk: supplierPart?.pk,
title: t`Edit Supplier Part`,
fields: supplierPartFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});
+5 -1
View File
@@ -41,6 +41,7 @@ import { type ReactNode, useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import Select from 'react-select';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@@ -190,7 +191,8 @@ export default function PartDetail() {
endpoint: ApiEndpoints.part_list,
pk: id,
params: {
path_detail: true
path_detail: true,
tags: true
},
refetchOnMount: true
});
@@ -612,6 +614,7 @@ export default function PartDetail() {
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={part.tags} />
{enableRevisionSelection && (
<Paper p='sm' withBorder>
<Stack gap='xs'>
@@ -998,6 +1001,7 @@ export default function PartDetail() {
pk: part.pk,
title: t`Edit Part`,
fields: partFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});
@@ -9,6 +9,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
@@ -65,7 +66,8 @@ export default function PurchaseOrderDetail() {
endpoint: ApiEndpoints.purchase_order_list,
pk: id,
params: {
supplier_detail: true
supplier_detail: true,
tags: true
},
refetchOnMount: true
});
@@ -89,6 +91,7 @@ export default function PurchaseOrderDetail() {
pk: id,
title: t`Edit Purchase Order`,
fields: purchaseOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => {
refreshInstance();
}
@@ -318,17 +321,20 @@ export default function PurchaseOrderDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.supplier_detail?.image}
pk={order.supplier}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.supplier_detail?.image}
pk={order.supplier}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
@@ -9,6 +9,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
@@ -66,7 +67,8 @@ export default function ReturnOrderDetail() {
endpoint: ApiEndpoints.return_order_list,
pk: id,
params: {
customer_detail: true
customer_detail: true,
tags: true
}
});
@@ -296,17 +298,20 @@ export default function ReturnOrderDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.customer_detail?.image}
pk={order.customer}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.customer_detail?.image}
pk={order.customer}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
@@ -403,6 +408,7 @@ export default function ReturnOrderDetail() {
pk: order.pk,
title: t`Edit Return Order`,
fields: returnOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => {
refreshInstance();
}
@@ -11,6 +11,7 @@ import { type ReactNode, useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { StylishText } from '@lib/components/StylishText';
import TagsList from '@lib/components/TagsList';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
@@ -75,7 +76,8 @@ export default function SalesOrderDetail() {
endpoint: ApiEndpoints.sales_order_list,
pk: id,
params: {
customer_detail: true
customer_detail: true,
tags: true
}
});
@@ -287,17 +289,20 @@ export default function SalesOrderDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.customer_detail?.image}
pk={order.customer}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.purchase_order}
apiPath={ApiEndpoints.company_list}
src={order.customer_detail?.image}
pk={order.customer}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
@@ -325,6 +330,7 @@ export default function SalesOrderDetail() {
pk: order.pk,
title: t`Edit Sales Order`,
fields: salesOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => {
refreshInstance();
}
@@ -13,6 +13,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { getDetailUrl } from '@lib/functions/Navigation';
import { TagsList } from '@lib/index';
import type { PanelType } from '@lib/types/Panel';
import AdminButton from '../../components/buttons/AdminButton';
import PrimaryActionButton from '../../components/buttons/PrimaryActionButton';
@@ -68,7 +69,8 @@ export default function SalesOrderShipmentDetail() {
endpoint: ApiEndpoints.sales_order_shipment_list,
pk: id,
params: {
order_detail: true
order_detail: true,
tags: true
}
});
@@ -221,23 +223,26 @@ export default function SalesOrderShipmentDetail() {
return (
<>
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.sales_order}
apiPath={ApiEndpoints.company_list}
src={customer?.image}
pk={customer?.pk}
imageActions={{
selectExisting: false,
downloadImage: false,
uploadFile: false,
deleteFile: false
}}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.sales_order}
apiPath={ApiEndpoints.company_list}
src={customer?.image}
pk={customer?.pk}
imageActions={{
selectExisting: false,
downloadImage: false,
uploadFile: false,
deleteFile: false
}}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={shipment.tags} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
@@ -295,6 +300,7 @@ export default function SalesOrderShipmentDetail() {
pk: shipment.pk,
fields: editShipmentFields,
title: t`Edit Shipment`,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshShipment
});
@@ -3,6 +3,7 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl } from '@lib/functions/Navigation';
import type { TableFilter } from '@lib/index';
import type { StockOperationProps } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel';
import { t } from '@lingui/core/macro';
@@ -60,6 +61,21 @@ import { StockLocationTable } from '../../tables/stock/StockLocationTable';
import TransferOrderParametricTable from '../../tables/stock/TransferOrderParametricTable';
import { TransferOrderTable } from '../../tables/stock/TransferOrderTable';
function TransferOrderCalendar() {
const calendarFilters: TableFilter[] = useMemo(() => {
return [];
}, []);
return (
<OrderCalendar
model={ModelType.transferorder}
role={UserRoles.transfer_order}
params={{ outstanding: true }}
filters={calendarFilters}
/>
);
}
export default function Stock() {
const { id: _id } = useParams();
@@ -247,13 +263,7 @@ export default function Stock() {
value: 'calendar',
label: t`Calendar View`,
icon: <IconCalendar />,
content: (
<OrderCalendar
model={ModelType.transferorder}
role={UserRoles.transfer_order}
params={{ outstanding: true }}
/>
)
content: <TransferOrderCalendar />
},
{
value: 'parametric',
+21 -14
View File
@@ -35,6 +35,7 @@ import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { apiUrl } from '@lib/functions/Api';
import { getDetailUrl, getOverviewUrl } from '@lib/functions/Navigation';
import { TagsList } from '@lib/index';
import type { ApiFormFieldSet, StockOperationProps } from '@lib/types/Forms';
import type { PanelType } from '@lib/types/Panel';
import { notifications } from '@mantine/notifications';
@@ -118,7 +119,8 @@ export default function StockDetail() {
params: {
part_detail: true,
location_detail: true,
path_detail: true
path_detail: true,
tags: true
}
});
@@ -445,19 +447,23 @@ export default function StockDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list}
src={
stockitem.part_detail?.image ?? stockitem?.part_detail?.thumbnail
}
pk={stockitem.part}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<Stack gap='xs'>
<Grid grow>
<DetailsImage
appRole={UserRoles.part}
apiPath={ApiEndpoints.part_list}
src={
stockitem.part_detail?.image ??
stockitem?.part_detail?.thumbnail
}
pk={stockitem.part}
/>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={data} />
</Grid.Col>
</Grid>
<TagsList tags={stockitem.tags} />
</Stack>
<DetailsTable fields={tr} item={data} />
<DetailsTable fields={bl} item={data} />
<DetailsTable fields={br} item={data} />
@@ -702,6 +708,7 @@ export default function StockDetail() {
title: t`Edit Stock Item`,
modalId: 'edit-stock-item',
fields: editStockItemFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: refreshInstance
});
@@ -6,7 +6,7 @@ import { useParams } from 'react-router-dom';
import { ApiEndpoints } from '@lib/enums/ApiEndpoints';
import { ModelType } from '@lib/enums/ModelType';
import { UserRoles } from '@lib/enums/Roles';
import { type PanelType, apiUrl } from '@lib/index';
import { type PanelType, TagsList, apiUrl } from '@lib/index';
import {
IconBookmark,
IconInfoCircle,
@@ -62,7 +62,9 @@ export default function TransferOrderDetail() {
} = useInstance({
endpoint: ApiEndpoints.transfer_order_list,
pk: id,
params: {}
params: {
tags: true
}
});
const toStatus = useStatusCodes({ modelType: ModelType.transferorder });
@@ -233,18 +235,21 @@ export default function TransferOrderDetail() {
return (
<ItemDetailsGrid>
<Grid grow>
{/* TODO: what image do we show for a Transfer Order? */}
{/* <DetailsImage
<Stack gap='xs'>
<Grid grow>
{/* TODO: what image do we show for a Transfer Order? */}
{/* <DetailsImage
appRole={UserRoles.transfer_order}
apiPath={ApiEndpoints.transfer_order_list}
src="/static/img/blank_image.png"
pk={order.pk}
/> */}
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<Grid.Col span={{ base: 12, sm: 8 }}>
<DetailsTable fields={tl} item={order} />
</Grid.Col>
</Grid>
<TagsList tags={order.tags} />
</Stack>
<DetailsTable fields={tr} item={order} />
<DetailsTable fields={bl} item={order} />
<DetailsTable fields={br} item={order} />
@@ -369,6 +374,7 @@ export default function TransferOrderDetail() {
pk: order.pk,
title: t`Edit Transfer Order`,
fields: transferOrderFields,
queryParams: new URLSearchParams({ tags: 'true' }),
onFormSuccess: () => {
refreshInstance();
}
+23
View File
@@ -399,6 +399,29 @@ export function ResponsibleFilter(): TableFilter {
});
}
export function TagsFilter({
modelType
}: {
modelType?: ModelType;
}): TableFilter {
return {
name: 'tags',
label: t`Tags`,
description: t`Filter by tags`,
placeholder: t`Select tags`,
type: 'api',
multi: true,
apiUrl: apiUrl(ApiEndpoints.tag_list),
model: ModelType.tag,
modelRenderer: (instance: any) => instance.name,
apiFilter: modelType ? { model_type: modelType } : undefined,
transform: (item: any) => ({
value: item.name.toString(),
label: item.name.toString()
})
};
}
export function UserFilter({
name,
label,
+276 -48
View File
@@ -7,6 +7,7 @@ import {
Divider,
Drawer,
Group,
MultiSelect,
Paper,
Select,
Space,
@@ -16,6 +17,7 @@ import {
Tooltip
} from '@mantine/core';
import { DateInput, type DateValue } from '@mantine/dates';
import { useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs';
import { useCallback, useEffect, useMemo, useState } from 'react';
@@ -26,7 +28,13 @@ import type {
TableFilterChoice,
TableFilterType
} from '@lib/types/Filters';
import { IconCheck } from '@tabler/icons-react';
import {
IconCheck,
IconFilterStar,
IconReload,
IconX
} from '@tabler/icons-react';
import { api } from '../App';
import { StandaloneField } from '../components/forms/StandaloneField';
import {
filterDisplayLabel,
@@ -106,6 +114,74 @@ function FilterItem({
);
}
/*
* Multi-select element for 'api' filters with multi=true.
* Fetches all options from the API then renders a searchable MultiSelect.
* The user picks multiple values and confirms with an Apply button.
*/
function MultiApiFilterElement({
filterProps,
onValueChange
}: Readonly<{
filterProps: TableFilter;
onValueChange: (value: string | null, displayValue?: any) => void;
}>) {
const [selected, setSelected] = useState<string[]>([]);
const query = useQuery({
queryKey: ['filter-options', filterProps.apiUrl, filterProps.apiFilter],
queryFn: () =>
api
.get(filterProps.apiUrl ?? '', { params: filterProps.apiFilter })
.then((r) => r.data),
enabled: !!filterProps.apiUrl
});
const options: TableFilterChoice[] = useMemo(() => {
const results: any[] = query.data?.results ?? query.data ?? [];
if (filterProps.transform) {
return results.map(filterProps.transform);
}
return results.map((item: any) => ({
value: String(item.pk ?? item.id ?? item.slug ?? item.value),
label: filterProps.modelRenderer?.(item) ?? String(item.name ?? item.pk)
}));
}, [query.data, filterProps.transform, filterProps.modelRenderer]);
const apply = useCallback(() => {
if (!selected.length) return;
const labels = selected.map(
(v) => options.find((o) => o.value === v)?.label ?? v
);
onValueChange(selected.join(','), labels.join(', '));
}, [selected, options, onValueChange]);
return (
<MultiSelect
data={options}
value={selected}
onChange={setSelected}
searchable
label={t`Value`}
placeholder={filterProps.placeholder ?? t`Select one or more values`}
maxDropdownHeight={400}
rightSectionPointerEvents='all'
rightSection={
<ActionIcon
aria-label='apply-tags-filter'
variant='transparent'
color='green'
size='md'
disabled={!selected.length}
onClick={apply}
>
<IconCheck />
</ActionIcon>
}
/>
);
}
function FilterElement({
filterName,
filterProps,
@@ -133,6 +209,14 @@ function FilterElement({
switch (filterProps.type) {
case 'api':
if (filterProps.multi) {
return (
<MultiApiFilterElement
filterProps={filterProps}
onValueChange={onValueChange}
/>
);
}
return (
<StandaloneField
fieldName={`filter-${filterName}`}
@@ -144,7 +228,15 @@ function FilterElement({
model: filterProps.model,
label: t`Select filter value`,
onValueChange: (value: any, instance: any) => {
onValueChange(value, filterProps.modelRenderer?.(instance));
if (filterProps.transform) {
const choice = filterProps.transform(instance);
onValueChange(choice.value, choice.label);
} else {
onValueChange(
value,
filterProps.modelRenderer?.(instance) ?? value
);
}
}
}}
/>
@@ -315,6 +407,77 @@ function FilterAddGroup({
);
}
function SavedFilterSets({
filterSet
}: Readonly<{
filterSet: FilterSetState;
}>) {
if (filterSet.savedFilterSets.length === 0) {
return null;
}
return (
<Stack gap='xs' justify='flex-end'>
<Space h='md' />
<StylishText size='md'>{t`Saved Filter Groups`}</StylishText>
<Divider />
<Stack gap='xs'>
{filterSet.savedFilterSets.map((set) => (
<Paper
key={`filter-group-${set.name}`}
p='sm'
shadow='sm'
radius='xs'
>
<Group justify='space-between' wrap='nowrap'>
<Group gap='xs' wrap='nowrap'>
<ActionIcon size='sm' variant='transparent'>
<IconFilterStar />
</ActionIcon>
<Text size='sm' style={{ flex: 1 }} truncate>
{set.name}
</Text>
</Group>
<Group gap='xs' wrap='nowrap'>
<Tooltip
label={t`Load filter group`}
withinPortal
position='top-end'
>
<ActionIcon
size='sm'
variant='transparent'
color='green'
aria-label={`load-filter-group-${set.name}`}
onClick={() => filterSet.loadFilterSet(set.name)}
>
<IconReload />
</ActionIcon>
</Tooltip>
<Tooltip
label={t`Delete filter group`}
withinPortal
position='top-end'
>
<ActionIcon
size='sm'
variant='transparent'
color='red'
aria-label={`delete-filter-group-${set.name}`}
onClick={() => filterSet.deleteFilterSet(set.name)}
>
<IconX style={{ transform: 'rotate(180deg)' }} />
</ActionIcon>
</Tooltip>
</Group>
</Group>
</Paper>
))}
</Stack>
</Stack>
);
}
export function FilterSelectDrawer({
title,
availableFilters,
@@ -329,6 +492,8 @@ export function FilterSelectDrawer({
onClose: () => void;
}>) {
const [addFilter, setAddFilter] = useState<boolean>(false);
const [saving, setSaving] = useState<boolean>(false);
const [saveName, setSaveName] = useState<string>('');
// Hide the "add filter" selection whenever the selected filters change
useEffect(() => {
@@ -336,14 +501,21 @@ export function FilterSelectDrawer({
}, [filterSet.activeFilters]);
const hasFilters: boolean = useMemo(() => {
const filters = filterSet?.activeFilters ?? [];
return filters.length > 0;
return (filterSet?.activeFilters ?? []).length > 0;
}, [filterSet.activeFilters]);
const confirmSave = useCallback(() => {
const name = saveName.trim();
if (name) {
filterSet.saveFilterSet(name);
}
setSaveName('');
setSaving(false);
}, [saveName, filterSet]);
return (
<Drawer
size='sm'
size='lg'
position='right'
withCloseButton={true}
opened={opened}
@@ -352,55 +524,111 @@ export function FilterSelectDrawer({
'aria-label': 'filter-drawer-close'
}}
title={<StylishText size='lg'>{title ?? t`Table Filters`}</StylishText>}
styles={{ body: { height: '100%', overflow: 'hidden' } }}
>
<Divider />
<Space h='sm' />
<Stack gap='xs'>
{hasFilters &&
filterSet.activeFilters?.map((f) => (
<FilterItem
key={f.name}
flt={f}
filterSet={filterSet}
availableFilters={availableFilters}
/>
))}
{addFilter && (
<Stack gap='xs'>
<FilterAddGroup
filterSet={filterSet}
availableFilters={availableFilters}
/>
</Stack>
)}
{addFilter && (
<Button
onClick={() => setAddFilter(false)}
color='orange'
variant='subtle'
>
<Text>{t`Cancel`}</Text>
</Button>
)}
{!addFilter &&
filterSet.activeFilters.length < availableFilters.length && (
<Stack gap='xs' justify='space-between'>
<Stack gap='xs'>
{hasFilters &&
filterSet.activeFilters?.map((f) => (
<FilterItem
key={f.name}
flt={f}
filterSet={filterSet}
availableFilters={availableFilters}
/>
))}
{addFilter && (
<Stack gap='xs'>
<FilterAddGroup
filterSet={filterSet}
availableFilters={availableFilters}
/>
</Stack>
)}
{addFilter && (
<Button
onClick={() => setAddFilter(true)}
color='green'
variant='subtle'
onClick={() => setAddFilter(false)}
color='orange'
variant='light'
>
<Text>{t`Add Filter`}</Text>
<Text>{t`Cancel`}</Text>
</Button>
)}
{!addFilter && filterSet.activeFilters.length > 0 && (
<Button
onClick={filterSet.clearActiveFilters}
color='red'
variant='subtle'
>
<Text>{t`Clear Filters`}</Text>
</Button>
)}
{!addFilter &&
filterSet.activeFilters.length < availableFilters.length && (
<Button
onClick={() => setAddFilter(true)}
color='green'
variant='light'
>
<Text>{t`Add Filter`}</Text>
</Button>
)}
{!addFilter && hasFilters && (
<Button
onClick={filterSet.clearActiveFilters}
color='red'
variant='light'
>
<Text>{t`Clear Filters`}</Text>
</Button>
)}
{!addFilter &&
hasFilters &&
(saving ? (
<Group gap='xs' wrap='nowrap'>
<TextInput
style={{ flex: 1 }}
aria-label='filter-group-name'
placeholder={t`Group name`}
value={saveName}
onChange={(e) => setSaveName(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') confirmSave();
if (e.key === 'Escape') setSaving(false);
}}
autoFocus
/>
<Tooltip label={t`Save`} withinPortal>
<ActionIcon
aria-label='save-filter-set'
color='green'
size='sm'
variant='transparent'
onClick={confirmSave}
disabled={!saveName.trim()}
>
<IconCheck />
</ActionIcon>
</Tooltip>
<Tooltip label={t`Cancel`} withinPortal>
<ActionIcon
aria-label='cancel-save-filter-set'
color='red'
size='sm'
variant='transparent'
onClick={() => setSaving(false)}
>
<IconX />
</ActionIcon>
</Tooltip>
</Group>
) : (
<Button
color='blue'
variant='light'
onClick={() => setSaving(true)}
>
<Text>{t`Save Filters`}</Text>
</Button>
))}
</Stack>
<Stack gap='xs'>
<SavedFilterSets filterSet={filterSet} />
<Space h='sm' />
</Stack>
</Stack>
</Drawer>
);
+22 -10
View File
@@ -255,16 +255,22 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
// Update column visibility when hiddenColumns change
const dataColumns: any = useMemo(() => {
let cols: TableColumn[] = columns.filter((col) => col?.hidden != true);
// Include all columns (even prop-hidden ones) so useDataTableColumns always
// has the full ordered list. This prevents dynamic columns (e.g. ones whose
// hidden flag depends on async data) from being treated as brand-new columns
// after load, which would place them after the ACTIONS column.
let cols: TableColumn[] = [...columns];
cols = cols.map((col) => {
// If the column is *not* switchable, it is always visible
// Otherwise, check if it is "default hidden"
// Prop-level hidden takes priority (e.g. hidden: !hasTrackedItems).
// For switchable columns, visibility is driven by tableState.hiddenColumns.
// Non-switchable columns are always visible (unless hidden by props).
const hidden: boolean =
col.switchable == false
? false
: (tableState.hiddenColumns?.includes(col.accessor) ?? false);
col.hidden === true
? true
: col.switchable == false
? false
: (tableState.hiddenColumns?.includes(col.accessor) ?? false);
// Wrap the render function with CopyableCell if copyable is enabled
const originalRender = col.render;
@@ -382,11 +388,17 @@ export function InvenTreeTableInternal<T extends Record<string, any>>({
getInitialValueInEffect: false
});
// Reset column ordering and custom widths when the component is mounted
// Reset column ordering when the column set changes (columns added/removed).
// Ref: https://github.com/icflorescu/mantine-datatable/issues/759
useEffect(() => {
tableColumns.setColumnsOrder(dataColumns.map((col: any) => col.accessor));
}, [tableColumnNames]);
const savedOrder = tableColumns.columnsOrder.join(',');
if (savedOrder != tableColumnNames) {
// This is covering the edge case where the column order is not updated automatically
tableColumns.resetColumnsOrder();
tableColumns.setColumnsOrder(tableColumnNames.split(','));
}
}, [tableColumnNames, tableColumns.columnsOrder]);
// Reset the pagination state when the search term changes
useEffect(() => {
@@ -22,6 +22,7 @@ import {
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter
} from '../Filter';
@@ -43,18 +44,19 @@ export default function BuildOrderFilters({
OrderStatusFilter({ model: ModelType.build }),
OverdueFilter(),
AssignedToMeFilter(),
CompletedBeforeFilter(),
CompletedAfterFilter(),
ProjectCodeFilter(),
HasProjectCodeFilter(),
IssuedByFilter(),
ResponsibleFilter(),
PartCategoryFilter()
PartCategoryFilter(),
TagsFilter({ modelType: ModelType.build })
];
const dateFilters: TableFilter[] = [
MinDateFilter(),
MaxDateFilter(),
CompletedBeforeFilter(),
CompletedAfterFilter(),
CreatedBeforeFilter(),
CreatedAfterFilter(),
TargetDateBeforeFilter(),
@@ -170,7 +170,8 @@ export default function BuildOutputTable({
// Fetch the test templates associated with the partId
const { data: testTemplates, refetch: refetchTestTemplates } = useQuery({
queryKey: ['buildoutputtests', partId, build],
staleTime: 60 * 1000, // Cache for 1 minute
queryKey: ['buildoutputtests', partId, buildId],
queryFn: async () => {
if (!partId || partId < 0) {
return [];
@@ -200,6 +201,7 @@ export default function BuildOutputTable({
// Fetch the "tracked" BOM items associated with the partId
const { data: trackedItems, refetch: refetchTrackedItems } = useQuery({
staleTime: 60 * 1000, // Cache for 1 minute
queryKey: ['trackeditems', buildId],
queryFn: async () => {
if (!buildId || buildId < 0) {
@@ -22,6 +22,7 @@ import {
CompanyColumn,
DescriptionColumn
} from '../ColumnRenderers';
import { TagsFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
/**
@@ -115,7 +116,8 @@ export function CompanyTable({
name: 'is_customer',
label: t`Customer`,
description: t`Show companies which are customers`
}
},
TagsFilter({ modelType: ModelType.company })
];
}, []);
@@ -1,5 +1,7 @@
import { ModelType } from '@lib/enums/ModelType';
import type { TableFilter } from '@lib/types/Filters';
import { t } from '@lingui/core/macro';
import { TagsFilter } from '../Filter';
/**
* Construct a set of filters for the part table
@@ -141,6 +143,9 @@ export function PartTableFilters(): TableFilter[] {
label: t`Subscribed`,
description: t`Filter by parts to which the user is subscribed`,
type: 'boolean'
}
},
TagsFilter({
modelType: ModelType.part
})
];
}
@@ -29,6 +29,7 @@ import {
LinkColumn,
PartColumn
} from '../ColumnRenderers';
import { TagsFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
/*
@@ -161,7 +162,8 @@ export function ManufacturerPartTable({
active: !manufacturerId,
description: t`Show manufacturer parts for active manufacturers.`,
type: 'boolean'
}
},
TagsFilter({ modelType: ModelType.manufacturerpart })
];
}, [manufacturerId]);
@@ -19,6 +19,7 @@ import {
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter,
UpdatedAfterFilter,
@@ -38,7 +39,8 @@ export default function PurchaseOrderFilters({
ProjectCodeFilter(),
HasProjectCodeFilter(),
ResponsibleFilter(),
CreatedByFilter()
CreatedByFilter(),
TagsFilter({ modelType: ModelType.purchaseorder })
];
const dateFilters: TableFilter[] = [
@@ -37,6 +37,7 @@ import {
NoteColumn,
PartColumn
} from '../ColumnRenderers';
import { TagsFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import { TableHoverCard } from '../TableHoverCard';
@@ -60,7 +61,8 @@ export function SupplierPartTable({
{
name: 'active',
value: 'true'
}
},
TagsFilter({ modelType: ModelType.supplierpart })
];
if (!supplierId) {
@@ -20,6 +20,7 @@ import {
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter,
UpdatedAfterFilter,
@@ -41,7 +42,8 @@ export default function ReturnOrderFilters({
HasProjectCodeFilter(),
ProjectCodeFilter(),
ResponsibleFilter(),
CreatedByFilter()
CreatedByFilter(),
TagsFilter({ modelType: ModelType.returnorder })
];
const dateFilters: TableFilter[] = [
@@ -20,6 +20,7 @@ import {
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter,
UpdatedAfterFilter,
@@ -41,7 +42,8 @@ export default function SalesOrderFilters({
HasProjectCodeFilter(),
ProjectCodeFilter(),
ResponsibleFilter(),
CreatedByFilter()
CreatedByFilter(),
TagsFilter({ modelType: ModelType.salesorder })
];
const dateFilters: TableFilter[] = [
@@ -25,7 +25,6 @@ import type { TableColumn } from '@lib/types/Tables';
import {
useCheckShipmentForm,
useCompleteShipmentForm,
useSalesOrderShipmentCompleteFields,
useSalesOrderShipmentFields,
useUncheckShipmentForm
} from '../../forms/SalesOrderForms';
@@ -41,6 +40,7 @@ import {
LinkColumn,
StatusColumn
} from '../ColumnRenderers';
import { TagsFilter } from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
export default function SalesOrderShipmentTable({
@@ -71,8 +71,6 @@ export default function SalesOrderShipmentTable({
pending: !selectedShipment.shipment_date
});
const completeShipmentFields = useSalesOrderShipmentCompleteFields({});
const newShipment = useCreateApiFormModal({
url: ApiEndpoints.sales_order_shipment_list,
fields: newShipmentFields,
@@ -303,7 +301,8 @@ export default function SalesOrderShipmentTable({
name: 'delivered',
label: t`Delivered`,
description: t`Show shipments which have been delivered`
}
},
TagsFilter({ modelType: ModelType.salesordershipment })
];
}, []);
@@ -44,6 +44,7 @@ import {
SerialLTEFilter,
StatusFilterOptions,
SupplierFilter,
TagsFilter,
UpdatedAfterFilter,
UpdatedBeforeFilter
} from '../Filter';
@@ -306,7 +307,8 @@ function stockItemTableFilters({
name: 'external',
label: t`External Location`,
description: t`Show items in an external location`
}
},
TagsFilter({ modelType: ModelType.stockitem })
];
}
@@ -0,0 +1,79 @@
import { ModelType, type TableFilter } from '@lib/index';
import { t } from '@lingui/core/macro';
import {
AssignedToMeFilter,
CompletedAfterFilter,
CompletedBeforeFilter,
CreatedAfterFilter,
CreatedBeforeFilter,
CreatedByFilter,
HasProjectCodeFilter,
IncludeVariantsFilter,
MaxDateFilter,
MinDateFilter,
OrderStatusFilter,
OutstandingFilter,
OverdueFilter,
ProjectCodeFilter,
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TagsFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter
} from '../Filter';
export default function TransferOrderFilters({
partId,
includeDateFilters = true
}: {
partId?: number;
includeDateFilters?: boolean;
}): TableFilter[] {
const filters: TableFilter[] = [
OrderStatusFilter({ model: ModelType.transferorder }),
OutstandingFilter(),
OverdueFilter(),
AssignedToMeFilter(),
HasProjectCodeFilter(),
ProjectCodeFilter(),
ResponsibleFilter(),
CreatedByFilter(),
TagsFilter({ modelType: ModelType.transferorder })
];
const dateFilters: TableFilter[] = [
MinDateFilter(),
MaxDateFilter(),
CreatedBeforeFilter(),
CreatedAfterFilter(),
TargetDateBeforeFilter(),
TargetDateAfterFilter(),
StartDateBeforeFilter(),
StartDateAfterFilter(),
{
name: 'has_target_date',
type: 'boolean',
label: t`Has Target Date`,
description: t`Show orders with a target date`
},
{
name: 'has_start_date',
type: 'boolean',
label: t`Has Start Date`,
description: t`Show orders with a start date`
},
CompletedBeforeFilter(),
CompletedAfterFilter()
];
if (includeDateFilters) {
filters.push(...dateFilters);
}
if (!!partId) {
filters.push(IncludeVariantsFilter());
}
return filters;
}
@@ -22,28 +22,8 @@ import {
StatusColumn,
TargetDateColumn
} from '../ColumnRenderers';
import {
AssignedToMeFilter,
CompletedAfterFilter,
CompletedBeforeFilter,
CreatedAfterFilter,
CreatedBeforeFilter,
CreatedByFilter,
HasProjectCodeFilter,
IncludeVariantsFilter,
MaxDateFilter,
MinDateFilter,
OrderStatusFilter,
OutstandingFilter,
OverdueFilter,
ProjectCodeFilter,
ResponsibleFilter,
StartDateAfterFilter,
StartDateBeforeFilter,
TargetDateAfterFilter,
TargetDateBeforeFilter
} from '../Filter';
import { InvenTreeTable } from '../InvenTreeTable';
import TransferOrderFilters from './TransferOrderFilters';
export function TransferOrderTable({
partId
@@ -56,45 +36,8 @@ export function TransferOrderTable({
const user = useUserState();
const tableFilters: TableFilter[] = useMemo(() => {
const filters: TableFilter[] = [
OrderStatusFilter({ model: ModelType.transferorder }),
OutstandingFilter(),
OverdueFilter(),
AssignedToMeFilter(),
MinDateFilter(),
MaxDateFilter(),
CreatedBeforeFilter(),
CreatedAfterFilter(),
TargetDateBeforeFilter(),
TargetDateAfterFilter(),
StartDateBeforeFilter(),
StartDateAfterFilter(),
{
name: 'has_target_date',
type: 'boolean',
label: t`Has Target Date`,
description: t`Show orders with a target date`
},
{
name: 'has_start_date',
type: 'boolean',
label: t`Has Start Date`,
description: t`Show orders with a start date`
},
CompletedBeforeFilter(),
CompletedAfterFilter(),
HasProjectCodeFilter(),
ProjectCodeFilter(),
ResponsibleFilter(),
CreatedByFilter()
];
if (!!partId) {
filters.push(IncludeVariantsFilter());
}
return filters;
}, [partId]);
return TransferOrderFilters({ includeDateFilters: true });
}, []);
const tableColumns = useMemo(() => {
return [
@@ -120,6 +120,51 @@ test('Build Order - Basic Tests', async ({ browser }) => {
.waitFor();
});
// Test tags filtering against Build Orders
test('Build Order - Tags', async ({ browser }) => {
const page = await doCachedLogin(browser, {
url: 'manufacturing/index/buildorders'
});
// Filter by tag
await page
.getByRole('button', { name: 'segmented-icon-control-table' })
.click();
await clearTableFilters(page);
await page.getByRole('button', { name: 'table-select-filters' }).click();
await page.getByRole('button', { name: 'Add Filter' }).click();
await page.getByRole('combobox', { name: 'Filter' }).fill('tag');
await page.getByRole('option', { name: 'Tags' }).click();
await page.getByRole('combobox', { name: 'Value' }).click();
// Check for expected tags
await page.getByRole('option', { name: 'Furniture' }).waitFor();
await page.getByRole('option', { name: 'Electronics' }).click();
await page.getByRole('option', { name: 'PCB Assembly' }).click();
// Apply the "Furniture" tag filter
await page.getByRole('button', { name: 'apply-tags-filter' }).click();
await page.getByRole('button', { name: 'filter-drawer-close' }).click();
// Check for expected results
await page.getByRole('cell', { name: 'BO0026' }).click();
await page.getByText('100 x 002.01-PCBA | Widget').waitFor();
// Check for tags displayed on BuildOrder detail page
await page.getByText('Electronics', { exact: true }).first().waitFor();
await page.getByText('PCB Assembly', { exact: true }).first().waitFor();
// Edit the build order
await page.keyboard.press('Control+E');
const tagsField = await page.getByRole('combobox', {
name: 'tags-field-tags'
});
await expect(tagsField).toBeVisible();
await page.getByRole('button', { name: 'Cancel' }).click();
});
// Test that the build order reference field increments correctly
test('Build Order - Reference', async ({ browser }) => {
const page = await doCachedLogin(browser, {
@@ -334,6 +379,24 @@ test('Build Order - Build Outputs', async ({ browser }) => {
)
.waitFor();
await page.getByRole('cell', { name: 'Quantity: 16' }).waitFor();
// Adjust the quantity field - we will only 'partially' scrap this output
await page.getByRole('textbox', { name: 'number-field-quantity' }).fill('10');
// Next, adjust the "location" field - and check that the "quantity" field does not change
// Ref: https://github.com/inventree/InvenTree/pull/12081
await page
.getByRole('combobox', { name: 'related-field-location' })
.fill('factory');
await page.getByTitle('Factory/Mechanical Lab').click();
await page.waitForTimeout(250);
// Check the 'quantity' value again - it should not have changed
const quantityValue = await page
.getByRole('textbox', { name: 'number-field-quantity' })
.inputValue();
expect(quantityValue).toBe('10');
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
});
@@ -276,6 +276,29 @@ test('Sales Orders - Shipments', async ({ browser }) => {
.click();
});
// Filter Shipments by tag
test('Sales Orders - Shipments - Tags', async ({ browser }) => {
const page = await doCachedLogin(browser, { url: 'sales/index/shipments' });
// Filter by tag
await clearTableFilters(page);
await page.getByRole('button', { name: 'table-select-filters' }).click();
await page.getByRole('button', { name: 'Add Filter' }).click();
await page.getByRole('combobox', { name: 'Filter' }).fill('tag');
await page.getByRole('option', { name: 'Tags' }).click();
await page.getByRole('combobox', { name: 'Value' }).click();
// Apply the "Requires Payment" tag filter
await page.getByRole('option', { name: 'Requires Payment' }).click();
await page.getByRole('button', { name: 'apply-tags-filter' }).click();
await page.getByRole('button', { name: 'filter-drawer-close' }).click();
// Click through to one of the selected shipments
await page.getByRole('cell', { name: 'SO0007' }).click();
await page.getByText('Sales Order: SO0007').first().waitFor();
await page.getByText('Requires Payment', { exact: true }).first().waitFor();
});
// Complete a shipment against a sales order
test('Sales Orders - Complete Shipment', async ({ browser }) => {
const page = await doCachedLogin(browser, {
+26
View File
@@ -3,6 +3,7 @@ import { stevenuser } from './defaults.js';
import {
clearTableFilters,
navigate,
openFilterDrawer,
setTableChoiceFilter,
toggleColumnSorting
} from './helpers.js';
@@ -39,6 +40,31 @@ test('Tables - Filters', async ({ browser }) => {
await setTableChoiceFilter(page, 'Has Start Date', 'Yes');
await clearTableFilters(page);
// Next, let's create a "custom filter group" and apply it
await openFilterDrawer(page);
await page.getByRole('button', { name: 'Add Filter' }).click();
await page.getByRole('combobox', { name: 'Filter' }).click();
await page.getByRole('option', { name: 'Outstanding' }).click();
await page.getByRole('combobox', { name: 'Value' }).click();
await page.getByRole('option', { name: 'Yes' }).click();
// Save the filter group
await page.getByRole('button', { name: 'Save Filters' }).click();
await page.getByRole('textbox', { name: 'filter-group-name' }).fill('custom');
await page
.getByRole('button', { name: 'save-filter-set', exact: true })
.click();
// Clear filters, and then restore from saved group
await page.getByRole('button', { name: 'Clear Filters' }).click();
await page.getByRole('button', { name: 'load-filter-group-custom' }).click();
await page.getByText('Show outstanding items').first().waitFor();
// Remove the filter group
await page
.getByRole('button', { name: 'delete-filter-group-custom' })
.click();
});
test('Tables - Pagination', async ({ browser }) => {