2
0
mirror of https://github.com/inventree/InvenTree.git synced 2025-04-28 19:46:46 +00:00

[PUI] Part charts fix (#8171)

* Refactor "stocktake" chart

- Format date axis correctly
- Better tooltips

* Fix datatable queries

- Prevent potential infinite loop

* Fix tooltip for BOM table

- Bug due to '.' char in names

* Do not reassign parameter

* Another fix

* Check global config value

* Cleanup tooltip for BOM pie

* Edit and refresh pricing from panel
This commit is contained in:
Oliver 2024-09-24 18:36:39 +10:00 committed by GitHub
parent b1c1981b6d
commit 0f2cfdcfd4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 295 additions and 63 deletions

View File

@ -93,7 +93,7 @@ export enum ApiEndpoints {
part_parameter_list = 'part/parameter/',
part_parameter_template_list = 'part/parameter/template/',
part_thumbs_list = 'part/thumbs/',
part_pricing_get = 'part/:id/pricing/',
part_pricing = 'part/:id/pricing/',
part_serial_numbers = 'part/:id/serial-numbers/',
part_scheduling = 'part/:id/scheduling/',
part_pricing_internal = 'part/internal-price/',

View File

@ -64,6 +64,7 @@ import {
IconProps,
IconQrcode,
IconQuestionMark,
IconRefresh,
IconRulerMeasure,
IconShoppingCart,
IconShoppingCartHeart,
@ -143,6 +144,7 @@ const icons = {
photo: IconPhoto,
upload: IconFileUpload,
reject: IconX,
refresh: IconRefresh,
select_image: IconGridDots,
delete: IconTrash,
packaging: IconPackage,

View File

@ -60,6 +60,6 @@ export function showLoginNotification({
color: success ? 'green' : 'red',
icon: success ? <IconCircleCheck /> : <IconExclamationCircle />,
id: 'login',
autoClose: 5000
autoClose: 2500
});
}

View File

@ -54,7 +54,7 @@ export type TableState = {
export function useTable(tableName: string): TableState {
// Function to generate a new ID (to refresh the table)
function generateTableName() {
return `${tableName}-${randomId()}`;
return `${tableName.replaceAll('-', '')}-${randomId()}`;
}
const [tableKey, setTableKey] = useState<string>(generateTableName());

View File

@ -55,7 +55,6 @@ import {
EditItemAction,
OptionsActionDropdown
} from '../../components/items/ActionDropdown';
import { PlaceholderPanel } from '../../components/items/Placeholder';
import { StylishText } from '../../components/items/StylishText';
import InstanceDetail from '../../components/nav/InstanceDetail';
import NavigationTree from '../../components/nav/NavigationTree';
@ -401,7 +400,7 @@ export default function PartDetail() {
const { data } = useSuspenseQuery({
queryKey: ['pricing', id],
queryFn: async () => {
const url = apiUrl(ApiEndpoints.part_pricing_get, null, {
const url = apiUrl(ApiEndpoints.part_pricing, null, {
id: id
});

View File

@ -5,6 +5,7 @@ import { useMemo, useState } from 'react';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { UserRoles } from '../../enums/Roles';
import { useInstance } from '../../hooks/UseInstance';
import { useGlobalSettingsState } from '../../states/SettingsState';
import { useUserState } from '../../states/UserState';
import BomPricingPanel from './pricing/BomPricingPanel';
import PriceBreakPanel from './pricing/PriceBreakPanel';
@ -31,15 +32,18 @@ export enum panelOptions {
export default function PartPricingPanel({ part }: Readonly<{ part: any }>) {
const user = useUserState();
const globalSettings = useGlobalSettingsState();
const { instance: pricing, instanceQuery } = useInstance({
pk: part?.pk,
hasPrimaryKey: true,
endpoint: ApiEndpoints.part_pricing_get,
endpoint: ApiEndpoints.part_pricing,
defaultValue: {}
});
// TODO: Do we display internal price? This is a global setting
const internalPricing = true;
const internalPricing: boolean = useMemo(() => {
return globalSettings.isSet('PART_INTERNAL_PRICE');
}, [globalSettings]);
const purchaseOrderPricing = useMemo(() => {
return user.hasViewRole(UserRoles.purchase_order) && part?.purchaseable;
@ -75,6 +79,7 @@ export default function PartPricingPanel({ part }: Readonly<{ part: any }>) {
<PricingOverviewPanel
part={part}
pricing={pricing}
pricingQuery={instanceQuery}
doNavigation={doNavigation}
/>
}

View File

@ -275,7 +275,7 @@ export default function PartSchedulingDetail({ part }: { part: any }) {
yAxisLabel={t`Expected Quantity`}
xAxisLabel={t`Date`}
xAxisProps={{
domain: [chartLimits[0], chartLimits[1]],
domain: chartLimits,
scale: 'time',
type: 'number',
tickFormatter: (value: number) => {

View File

@ -1,10 +1,17 @@
import { t } from '@lingui/macro';
import { LineChart } from '@mantine/charts';
import { Center, Loader, SimpleGrid } from '@mantine/core';
import { ChartTooltipProps, LineChart } from '@mantine/charts';
import {
Center,
Divider,
Loader,
Paper,
SimpleGrid,
Text
} from '@mantine/core';
import { useCallback, useMemo, useState } from 'react';
import { AddItemButton } from '../../components/buttons/AddItemButton';
import { formatPriceRange } from '../../defaults/formatters';
import { formatDate, formatPriceRange } from '../../defaults/formatters';
import { ApiEndpoints } from '../../enums/ApiEndpoints';
import { UserRoles } from '../../enums/Roles';
import {
@ -23,6 +30,42 @@ import { TableColumn } from '../../tables/Column';
import { InvenTreeTable } from '../../tables/InvenTreeTable';
import { RowDeleteAction, RowEditAction } from '../../tables/RowActions';
/*
* Render a tooltip for the chart, with correct date information
*/
function ChartTooltip({ label, payload }: ChartTooltipProps) {
const formattedLabel: string = useMemo(() => {
if (label && typeof label === 'number') {
return formatDate(new Date(label).toISOString()) ?? label;
} else if (!!label) {
return label.toString();
} else {
return '';
}
}, [label]);
if (!payload) {
return null;
}
const quantity = payload.find((item) => item.name == 'quantity');
const value_min = payload.find((item) => item.name == 'value_min');
const value_max = payload.find((item) => item.name == 'value_max');
return (
<Paper px="md" py="sm" withBorder shadow="md" radius="md">
<Text key="title">{formattedLabel}</Text>
<Divider />
<Text key="quantity" fz="sm">
{t`Quantity`} : {quantity?.value}
</Text>
<Text key="values" fz="sm">
{t`Value`} : {formatPriceRange(value_min?.value, value_max?.value)}
</Text>
</Paper>
);
}
export default function PartStocktakeDetail({ partId }: { partId: number }) {
const user = useUserState();
const table = useTable('part-stocktake');
@ -126,7 +169,7 @@ export default function PartStocktakeDetail({ partId }: { partId: number }) {
let records =
table.records?.map((record: any) => {
return {
date: record.date,
date: new Date(record.date).valueOf(),
quantity: record.quantity,
value_min: record.cost_min,
value_max: record.cost_max
@ -135,12 +178,29 @@ export default function PartStocktakeDetail({ partId }: { partId: number }) {
// Sort records to ensure correct date order
records.sort((a, b) => {
return new Date(a.date) < new Date(b.date) ? -1 : 1;
return a < b ? -1 : 1;
});
return records;
}, [table.records]);
// Calculate the date limits of the chart
const chartLimits: number[] = useMemo(() => {
let min_date = new Date();
let max_date = new Date();
if (chartData.length > 0) {
min_date = new Date(chartData[0].date);
max_date = new Date(chartData[chartData.length - 1].date);
}
// Expand limits by one day on either side
min_date.setDate(min_date.getDate() - 1);
max_date.setDate(max_date.getDate() + 1);
return [min_date.valueOf(), max_date.valueOf()];
}, [chartData]);
return (
<>
{generateReport.modal}
@ -172,6 +232,19 @@ export default function PartStocktakeDetail({ partId }: { partId: number }) {
withRightYAxis
yAxisLabel={t`Quantity`}
rightYAxisLabel={t`Stock Value`}
tooltipProps={{
content: ({ label, payload }) => (
<ChartTooltip label={label} payload={payload} />
)
}}
xAxisProps={{
scale: 'time',
type: 'number',
domain: chartLimits,
tickFormatter: (value: number) => {
return formatDate(new Date(value).toISOString());
}
}}
series={[
{
name: 'quantity',
@ -181,14 +254,14 @@ export default function PartStocktakeDetail({ partId }: { partId: number }) {
},
{
name: 'value_min',
label: t`Min Value`,
color: 'teal.6',
label: t`Minimum Value`,
color: 'yellow.6',
yAxisId: 'right'
},
{
name: 'value_max',
label: t`Max Value`,
color: 'red.6',
label: t`Maximum Value`,
color: 'teal.6',
yAxisId: 'right'
}
]}

View File

@ -1,8 +1,9 @@
import { t } from '@lingui/macro';
import { BarChart, DonutChart } from '@mantine/charts';
import { BarChart, ChartTooltipProps, DonutChart } from '@mantine/charts';
import {
Center,
Group,
Paper,
SegmentedControl,
SimpleGrid,
Stack,
@ -12,7 +13,11 @@ import { ReactNode, useMemo, useState } from 'react';
import { CHART_COLORS } from '../../../components/charts/colors';
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
import { formatDecimal, formatPriceRange } from '../../../defaults/formatters';
import {
formatCurrency,
formatDecimal,
formatPriceRange
} from '../../../defaults/formatters';
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
import { ModelType } from '../../../enums/ModelType';
import { useTable } from '../../../hooks/UseTable';
@ -22,6 +27,30 @@ import { DateColumn, PartColumn } from '../../../tables/ColumnRenderers';
import { InvenTreeTable } from '../../../tables/InvenTreeTable';
import { LoadingPricingData, NoPricingData } from './PricingPanel';
/*
* Render a tooltip for the chart, with correct date information
*/
function ChartTooltip({ label, payload }: ChartTooltipProps) {
if (!payload) {
return null;
}
const data = payload[0] ?? {};
return (
<Paper px="md" py="sm" withBorder shadow="md" radius="md">
<Group justify="space-between" wrap="nowrap">
<Text key="title" c={data.payload?.color}>
{data.name}
</Text>
<Text key="price" fz="sm">
{formatCurrency(data.payload?.value)}
</Text>
</Group>
</Paper>
);
}
// Display BOM data as a pie chart
function BomPieChart({
data,
@ -57,6 +86,11 @@ function BomPieChart({
tooltipDataSource="segment"
chartLabel={t`Total Price`}
valueFormatter={(value) => tooltipFormatter(value, currency)}
tooltipProps={{
content: ({ label, payload }) => (
<ChartTooltip label={label} payload={payload} />
)
}}
/>
</Center>
);
@ -78,10 +112,15 @@ function BomBarChart({
xAxisLabel={t`Component`}
yAxisLabel={t`Price Range`}
series={[
{ name: 'total_price_min', label: t`Minimum Price`, color: 'blue.6' },
{ name: 'total_price_min', label: t`Minimum Price`, color: 'yellow.6' },
{ name: 'total_price_max', label: t`Maximum Price`, color: 'teal.6' }
]}
valueFormatter={(value) => tooltipFormatter(value, currency)}
tooltipProps={{
content: ({ label, payload }) => (
<ChartTooltip label={label} payload={payload} />
)
}}
/>
);
}
@ -93,7 +132,7 @@ export default function BomPricingPanel({
readonly part: any;
readonly pricing: any;
}): ReactNode {
const table = useTable('pricing-bom');
const table = useTable('pricingbom');
const columns: TableColumn[] = useMemo(() => {
return [

View File

@ -34,7 +34,7 @@ export default function PriceBreakPanel({
endpoint: ApiEndpoints;
}>) {
const user = useUserState();
const table = useTable('pricing-internal');
const table = useTable('pricinginternal');
const priceBreakFields: ApiFormFieldSet = useMemo(() => {
return {

View File

@ -9,20 +9,33 @@ import {
Stack,
Text
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import {
IconBuildingWarehouse,
IconChartDonut,
IconCircleCheck,
IconExclamationCircle,
IconList,
IconReportAnalytics,
IconShoppingCart,
IconTriangleSquareCircle
} from '@tabler/icons-react';
import { UseQueryResult } from '@tanstack/react-query';
import { toggleUnorderedList } from 'easymde';
import { DataTable } from 'mantine-datatable';
import { ReactNode, useMemo } from 'react';
import { ReactNode, useCallback, useMemo } from 'react';
import { api } from '../../../App';
import { tooltipFormatter } from '../../../components/charts/tooltipFormatter';
import {
EditItemAction,
OptionsActionDropdown
} from '../../../components/items/ActionDropdown';
import { formatCurrency, formatDate } from '../../../defaults/formatters';
import { ApiEndpoints } from '../../../enums/ApiEndpoints';
import { InvenTreeIcon } from '../../../functions/icons';
import { useEditApiFormModal } from '../../../hooks/UseForm';
import { apiUrl } from '../../../states/ApiState';
import { panelOptions } from '../PartPricingPanel';
interface PricingOverviewEntry {
@ -38,12 +51,73 @@ interface PricingOverviewEntry {
export default function PricingOverviewPanel({
part,
pricing,
pricingQuery,
doNavigation
}: Readonly<{
part: any;
pricing: any;
pricingQuery: UseQueryResult;
doNavigation: (panel: panelOptions) => void;
}>): ReactNode {
const refreshPricing = useCallback(() => {
const url = apiUrl(ApiEndpoints.part_pricing, part.pk);
notifications.hide('pricing-refresh');
notifications.show({
message: t`Refreshing pricing data`,
color: 'green',
id: 'pricing-refresh',
loading: true,
autoClose: false
});
let success: boolean = false;
api
.patch(url, { update: true })
.then((response) => {
success = response.status === 200;
})
.catch(() => {})
.finally(() => {
notifications.hide('pricing-refresh');
if (success) {
notifications.show({
message: t`Pricing data updated`,
color: 'green',
icon: <IconCircleCheck />
});
pricingQuery.refetch();
} else {
notifications.show({
message: t`Failed to update pricing data`,
color: 'red',
icon: <IconExclamationCircle />
});
}
});
}, [part]);
const editPricing = useEditApiFormModal({
title: t`Edit Pricing`,
url: apiUrl(ApiEndpoints.part_pricing, part.pk),
fields: {
override_min: {},
override_min_currency: {},
override_max: {},
override_max_currency: {},
update: {
hidden: true,
value: true
}
},
onFormSuccess: () => {
pricingQuery.refetch();
}
});
const columns: any[] = useMemo(() => {
return [
{
@ -163,38 +237,70 @@ export default function PricingOverviewPanel({
});
}, [part, pricing]);
// TODO: Add display of "last updated"
// TODO: Add "update now" button
return (
<Stack gap="xs">
<SimpleGrid cols={2}>
<Stack gap="xs">
{pricing?.updated && (
<>
{editPricing.modal}
<Stack gap="xs">
<SimpleGrid cols={2}>
<Stack gap="xs">
<Paper p="xs">
<Alert color="blue" title={t`Last Updated`}>
<Text>{formatDate(pricing.updated)}</Text>
</Alert>
<Group justify="space-between" wrap="nowrap">
{pricing?.updated ? (
<Alert color="blue" title={t`Last Updated`} flex={1}>
<Text>{formatDate(pricing.updated)}</Text>
</Alert>
) : (
<Alert color="orange" title={t`Pricing Not Set`} flex={1}>
<Text>{t`Pricing data has not been calculated for this part`}</Text>
</Alert>
)}
<OptionsActionDropdown
tooltip={t`Pricing Actions`}
actions={[
{
name: t`Refresh`,
tooltip: t`Refresh pricing data`,
icon: (
<InvenTreeIcon
icon="refresh"
iconProps={{ color: 'green' }}
/>
),
onClick: () => {
refreshPricing();
}
},
EditItemAction({
onClick: () => {
editPricing.open();
},
tooltip: t`Edit pricing data`
})
]}
/>
</Group>
</Paper>
)}
<DataTable
idAccessor="name"
records={overviewData}
columns={columns}
<DataTable
idAccessor="name"
records={overviewData}
columns={columns}
/>
</Stack>
<BarChart
aria-label="pricing-overview-chart"
dataKey="title"
data={overviewData}
title={t`Pricing Overview`}
series={[
{ name: 'min_value', label: t`Minimum Value`, color: 'blue.6' },
{ name: 'max_value', label: t`Maximum Value`, color: 'teal.6' }
]}
valueFormatter={(value) =>
tooltipFormatter(value, pricing?.currency)
}
/>
</Stack>
<BarChart
aria-label="pricing-overview-chart"
dataKey="title"
data={overviewData}
title={t`Pricing Overview`}
series={[
{ name: 'min_value', label: t`Minimum Value`, color: 'blue.6' },
{ name: 'max_value', label: t`Maximum Value`, color: 'teal.6' }
]}
valueFormatter={(value) => tooltipFormatter(value, pricing?.currency)}
/>
</SimpleGrid>
</Stack>
</SimpleGrid>
</Stack>
</>
);
}

View File

@ -16,7 +16,7 @@ export default function PurchaseHistoryPanel({
}: Readonly<{
part: any;
}>): ReactNode {
const table = useTable('pricing-purchase-history');
const table = useTable('pricingpurchasehistory');
const calculateUnitPrice = useCallback((record: any) => {
let pack_quantity = record?.supplier_part_detail?.pack_quantity_native ?? 1;

View File

@ -15,7 +15,7 @@ import { NoPricingData } from './PricingPanel';
export default function SaleHistoryPanel({
part
}: Readonly<{ part: any }>): ReactNode {
const table = useTable('pricing-sale-history');
const table = useTable('pricingsalehistory');
const columns: TableColumn[] = useMemo(() => {
return [

View File

@ -18,7 +18,7 @@ import { NoPricingData } from './PricingPanel';
export default function SupplierPricingPanel({
part
}: Readonly<{ part: any }>) {
const table = useTable('pricing-supplier');
const table = useTable('pricingsupplier');
const columns: TableColumn[] = useMemo(() => {
return SupplierPriceBreakColumns();

View File

@ -21,7 +21,7 @@ export default function VariantPricingPanel({
part: any;
pricing: any;
}>): ReactNode {
const table = useTable('pricing-variants');
const table = useTable('pricingvariants');
const columns: TableColumn[] = useMemo(() => {
return [

View File

@ -79,7 +79,7 @@ export const useLocalState = create<LocalStateProps>()(
// tables
tableColumnNames: {},
getTableColumnNames: (tableKey) => {
return get().tableColumnNames[tableKey] || {};
return get().tableColumnNames[tableKey] || null;
},
setTableColumnNames: (tableKey) => (names) => {
// Update the table column names for the given table

View File

@ -179,10 +179,17 @@ export function InvenTreeTable<T extends Record<string, any>>({
queryKey: ['options', url, tableState.tableKey, props.enableColumnCaching],
retry: 3,
refetchOnMount: true,
gcTime: 5000,
queryFn: async () => {
if (props.enableColumnCaching == false) {
return null;
}
// If we already have field names, no need to fetch them again
if (fieldNames && Object.keys(fieldNames).length > 0) {
return null;
}
return api
.options(url, {
params: tableProps.params
@ -203,7 +210,7 @@ export function InvenTreeTable<T extends Record<string, any>>({
}
});
const cacheKey = tableState.tableKey.split('-')[0];
const cacheKey = tableState.tableKey.replaceAll('-', '');
setFieldNames(names);
setTableColumnNames(cacheKey)(names);
@ -220,20 +227,19 @@ export function InvenTreeTable<T extends Record<string, any>>({
return;
}
const cacheKey = tableState.tableKey.split('-')[0];
const cacheKey = tableState.tableKey.replaceAll('-', '');
// First check the local cache
const cachedNames = getTableColumnNames(cacheKey);
if (Object.keys(cachedNames).length > 0) {
if (cachedNames != null) {
// Cached names are available - use them!
setFieldNames(cachedNames);
return;
}
// Otherwise, fetch the data from the API
tableOptionQuery.refetch();
}, [url, tableState.tableKey, props.params, props.enableColumnCaching]);
}, [url, props.params, props.enableColumnCaching]);
// Build table properties based on provided props (and default props)
const tableProps: InvenTreeTableProps<T> = useMemo(() => {
@ -502,6 +508,8 @@ export function InvenTreeTable<T extends Record<string, any>>({
const { data, isFetching, isLoading, refetch } = useQuery({
queryKey: [
'tabledata',
url,
tableState.page,
props.params,
sortStatus.columnAccessor,