diff --git a/src/frontend/src/enums/ApiEndpoints.tsx b/src/frontend/src/enums/ApiEndpoints.tsx
index 4295f70b0f..7e36c82809 100644
--- a/src/frontend/src/enums/ApiEndpoints.tsx
+++ b/src/frontend/src/enums/ApiEndpoints.tsx
@@ -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/',
diff --git a/src/frontend/src/functions/icons.tsx b/src/frontend/src/functions/icons.tsx
index d9047e3575..caa5d75f42 100644
--- a/src/frontend/src/functions/icons.tsx
+++ b/src/frontend/src/functions/icons.tsx
@@ -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,
diff --git a/src/frontend/src/functions/notifications.tsx b/src/frontend/src/functions/notifications.tsx
index 79550e65a3..7b36efbcdc 100644
--- a/src/frontend/src/functions/notifications.tsx
+++ b/src/frontend/src/functions/notifications.tsx
@@ -60,6 +60,6 @@ export function showLoginNotification({
color: success ? 'green' : 'red',
icon: success ? : ,
id: 'login',
- autoClose: 5000
+ autoClose: 2500
});
}
diff --git a/src/frontend/src/hooks/UseTable.tsx b/src/frontend/src/hooks/UseTable.tsx
index c44cc11d9e..a68e84a1fe 100644
--- a/src/frontend/src/hooks/UseTable.tsx
+++ b/src/frontend/src/hooks/UseTable.tsx
@@ -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(generateTableName());
diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx
index c6758b035d..9d095c9c49 100644
--- a/src/frontend/src/pages/part/PartDetail.tsx
+++ b/src/frontend/src/pages/part/PartDetail.tsx
@@ -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
});
diff --git a/src/frontend/src/pages/part/PartPricingPanel.tsx b/src/frontend/src/pages/part/PartPricingPanel.tsx
index 5bdb788695..0fdc1c6191 100644
--- a/src/frontend/src/pages/part/PartPricingPanel.tsx
+++ b/src/frontend/src/pages/part/PartPricingPanel.tsx
@@ -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 }>) {
}
diff --git a/src/frontend/src/pages/part/PartSchedulingDetail.tsx b/src/frontend/src/pages/part/PartSchedulingDetail.tsx
index 8121f3426f..8ac903029f 100644
--- a/src/frontend/src/pages/part/PartSchedulingDetail.tsx
+++ b/src/frontend/src/pages/part/PartSchedulingDetail.tsx
@@ -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) => {
diff --git a/src/frontend/src/pages/part/PartStocktakeDetail.tsx b/src/frontend/src/pages/part/PartStocktakeDetail.tsx
index bd384ab85f..956680fabf 100644
--- a/src/frontend/src/pages/part/PartStocktakeDetail.tsx
+++ b/src/frontend/src/pages/part/PartStocktakeDetail.tsx
@@ -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 (
+
+ {formattedLabel}
+
+
+ {t`Quantity`} : {quantity?.value}
+
+
+ {t`Value`} : {formatPriceRange(value_min?.value, value_max?.value)}
+
+
+ );
+}
+
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 }) => (
+
+ )
+ }}
+ 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'
}
]}
diff --git a/src/frontend/src/pages/part/pricing/BomPricingPanel.tsx b/src/frontend/src/pages/part/pricing/BomPricingPanel.tsx
index 3b992db87e..b3c1a01c2f 100644
--- a/src/frontend/src/pages/part/pricing/BomPricingPanel.tsx
+++ b/src/frontend/src/pages/part/pricing/BomPricingPanel.tsx
@@ -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 (
+
+
+
+ {data.name}
+
+
+ {formatCurrency(data.payload?.value)}
+
+
+
+ );
+}
+
// 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 }) => (
+
+ )
+ }}
/>
);
@@ -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 }) => (
+
+ )
+ }}
/>
);
}
@@ -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 [
diff --git a/src/frontend/src/pages/part/pricing/PriceBreakPanel.tsx b/src/frontend/src/pages/part/pricing/PriceBreakPanel.tsx
index 9b269b9821..ba80168720 100644
--- a/src/frontend/src/pages/part/pricing/PriceBreakPanel.tsx
+++ b/src/frontend/src/pages/part/pricing/PriceBreakPanel.tsx
@@ -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 {
diff --git a/src/frontend/src/pages/part/pricing/PricingOverviewPanel.tsx b/src/frontend/src/pages/part/pricing/PricingOverviewPanel.tsx
index 5036c51250..a4266db7d4 100644
--- a/src/frontend/src/pages/part/pricing/PricingOverviewPanel.tsx
+++ b/src/frontend/src/pages/part/pricing/PricingOverviewPanel.tsx
@@ -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:
+ });
+ pricingQuery.refetch();
+ } else {
+ notifications.show({
+ message: t`Failed to update pricing data`,
+ color: 'red',
+ icon:
+ });
+ }
+ });
+ }, [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 (
-
-
-
- {pricing?.updated && (
+ <>
+ {editPricing.modal}
+
+
+
-
- {formatDate(pricing.updated)}
-
+
+ {pricing?.updated ? (
+
+ {formatDate(pricing.updated)}
+
+ ) : (
+
+ {t`Pricing data has not been calculated for this part`}
+
+ )}
+
+ ),
+ onClick: () => {
+ refreshPricing();
+ }
+ },
+ EditItemAction({
+ onClick: () => {
+ editPricing.open();
+ },
+ tooltip: t`Edit pricing data`
+ })
+ ]}
+ />
+
- )}
-
+
+
+ tooltipFormatter(value, pricing?.currency)
+ }
/>
-
- tooltipFormatter(value, pricing?.currency)}
- />
-
-
+
+
+ >
);
}
diff --git a/src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx b/src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx
index 29e4c5de6c..99245bbc99 100644
--- a/src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx
+++ b/src/frontend/src/pages/part/pricing/PurchaseHistoryPanel.tsx
@@ -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;
diff --git a/src/frontend/src/pages/part/pricing/SaleHistoryPanel.tsx b/src/frontend/src/pages/part/pricing/SaleHistoryPanel.tsx
index 359f003463..2512d716a1 100644
--- a/src/frontend/src/pages/part/pricing/SaleHistoryPanel.tsx
+++ b/src/frontend/src/pages/part/pricing/SaleHistoryPanel.tsx
@@ -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 [
diff --git a/src/frontend/src/pages/part/pricing/SupplierPricingPanel.tsx b/src/frontend/src/pages/part/pricing/SupplierPricingPanel.tsx
index b290c64251..189e7b56f0 100644
--- a/src/frontend/src/pages/part/pricing/SupplierPricingPanel.tsx
+++ b/src/frontend/src/pages/part/pricing/SupplierPricingPanel.tsx
@@ -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();
diff --git a/src/frontend/src/pages/part/pricing/VariantPricingPanel.tsx b/src/frontend/src/pages/part/pricing/VariantPricingPanel.tsx
index 28f3d19774..687f351423 100644
--- a/src/frontend/src/pages/part/pricing/VariantPricingPanel.tsx
+++ b/src/frontend/src/pages/part/pricing/VariantPricingPanel.tsx
@@ -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 [
diff --git a/src/frontend/src/states/LocalState.tsx b/src/frontend/src/states/LocalState.tsx
index 6077665dda..ba783b04c6 100644
--- a/src/frontend/src/states/LocalState.tsx
+++ b/src/frontend/src/states/LocalState.tsx
@@ -79,7 +79,7 @@ export const useLocalState = create()(
// 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
diff --git a/src/frontend/src/tables/InvenTreeTable.tsx b/src/frontend/src/tables/InvenTreeTable.tsx
index f5e283c28c..40169e46f7 100644
--- a/src/frontend/src/tables/InvenTreeTable.tsx
+++ b/src/frontend/src/tables/InvenTreeTable.tsx
@@ -179,10 +179,17 @@ export function InvenTreeTable>({
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>({
}
});
- const cacheKey = tableState.tableKey.split('-')[0];
+ const cacheKey = tableState.tableKey.replaceAll('-', '');
setFieldNames(names);
setTableColumnNames(cacheKey)(names);
@@ -220,20 +227,19 @@ export function InvenTreeTable>({
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 = useMemo(() => {
@@ -502,6 +508,8 @@ export function InvenTreeTable>({
const { data, isFetching, isLoading, refetch } = useQuery({
queryKey: [
+ 'tabledata',
+ url,
tableState.page,
props.params,
sortStatus.columnAccessor,