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,